/* Inter, vendored. Latin subset, 400/500/600 plus the 400 italic, from
   @fontsource/inter 5.3.0 (https://cdn.jsdelivr.net/npm/@fontsource/inter@5.3.0/files/),
   which repackages the Inter project's own builds. Inter is under the SIL Open Font
   Licence 1.1 - the licence is kept beside the files, in static/fonts/LICENSE.txt.

   Why this is here at all: the body stack used to start at -apple-system, which is SF Pro
   on a Mac and nothing anywhere else. Windows fell through to Arial and the Pi to DejaVu
   Sans, so nobody but a Mac user saw the face the page was designed against. Same reason
   the files are vendored rather than linked: this is an appliance, and its LAN may have no
   way out to the internet.

   font-display: swap keeps the text visible while the file loads - and on a LAN the file
   is there long before the first paint, so nothing actually swaps. The url() is relative,
   so it resolves against this stylesheet's own /static/ prefix and does not care what
   mounted it. */
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url(fonts/inter-latin-400-normal.woff2) format('woff2');
}
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url(fonts/inter-latin-500-normal.woff2) format('woff2');
}
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url(fonts/inter-latin-600-normal.woff2) format('woff2');
}
/* The page sets three words in italic - "Open Network Stream" in the stream card's hint.
   Declaring the real italic keeps the browser from slanting the roman face to fake it. */
@font-face {
  font-family: 'Inter';
  font-style: italic;
  font-weight: 400;
  font-display: swap;
  src: url(fonts/inter-latin-400-italic.woff2) format('woff2');
}

/* Colours are named once here and used by every rule below, so a change to the page's colour
   is one block rather than a hunt through the file. `color-scheme` also tells the browser to
   draw the form controls, the file picker and the scrollbars to match - and it is `dark` on
   its own rather than `light dark` because there is one scheme now, so a light scrollbar down
   the side of this page is the one part of it the palette cannot reach.

   Beside the palette sit the two scales every other rule is written against: spacing on a
   4px base, and type. They exist so a rule cannot quietly invent a number - the values that
   used to be written inline (a 6px margin between buttons, 0.9rem for fine print, 1.35rem
   for the playing title) are now one of these names. The page frame is the deliberate
   exception: body's 40px top margin and 20px padding, and the card's own 20px padding, are
   container metrics rather than component rhythm, and each has a measured number quoted in
   the comments below. */
:root {
  color-scheme: dark;

  /* Palette. The filled variants - accent, danger, warn - step a shade *darker* on hover and
     again on active, which is what the accent in particular has to do: white sits on
     `--accent-fill`, and a lighter blue would fall back under the 3:1 floor that fill exists
     to clear. `.secondary` is a surface rather than a colour and moves the other way, a step
     lighter on hover and a step darker on active. */
  --bg: #16181c;
  --fg: #e8e8ea;
  --fg-soft: #c8ccd2;
  --muted: #a2a6ad;
  --card-bg: #1f2227;
  --card-border: #34383e;
  --drop-bg: #24272d;
  /* Measured against the drop zone's own fill, which is what this border exists to mark out:
     3.76:1 against the #24272d above, and 4.01:1 against the #1f2227 card it sits on. It was
     #565b62, which measured 2.19:1 against the fill and 2.33:1 against the card - under the
     3:1 floor for a non-text graphic, and with only one scheme left there is nothing else
     marking the drop area out. */
  --drop-border: #7a818b;
  --accent: #5aa2ff;
  --accent-soft: #1d2a3d;
  /* White on the accent's own #5aa2ff measures 2.61:1 - below AA for text and below even the
     3:1 floor that applies to a plain graphic - and that was the fill behind Connect and all
     four transport buttons. This blue is dark enough to carry white (5.2:1) and still reads
     as the same colour. Hover and active step *darker* rather than lighter, deliberately: a
     lighter blue would fall back under the threshold these exist to clear. */
  --accent-fill: #1a6ad4;
  --accent-fill-hover: #1660c2;
  --accent-fill-active: #1255ae;
  /* Every filled variant carries a rim in a shade of its own fill - the relationship the quiet
     `.secondary` variant already had, and the reason the base rule keeps a 1px border in the
     box model on every button. It is the variant's own pressed shade, so the rim is not a new
     colour and it deepens with the fill on hover. It is darker than the fill, which is the
     direction that was asked for - an earlier pass had it lighter on the dark surface, on the
     usual theory that a dark background wants a highlight, and that was sent back. The accent
     is the one rim with a value of its own, because its fill is the one that had to be lifted
     off #0066cc before white text could sit on it; `.danger` and `.warn` have their rims
     stated beside their fills below. */
  --accent-fill-border: #1255ae;
  /* Crimson for the destructive control and burnt orange for Panic / Reset, each rimmed in its
     own pressed shade. These eight were restated in the dark block while there were two
     schemes, so that retuning the light one could not reach the dark one by accident; with one
     scheme there is nothing left for that to protect. */
  --danger-bg: #cc3333;
  --danger-hover: #b32d2d;
  --danger-active: #9c2727;
  --danger-border: #9c2727;
  /* White on the old #e67e22 measured 2.85:1 - worse than the accent. This is the deeper burnt
     orange that replaced it, measured at 5.18:1 over white. It spent one pass at hue 26
     instead of this hue 18 - also over AA, at 5.02:1 - and came back: the cooler, more
     saturated orange reads as brown rather than as the alarm button, so the reddish one is the
     one that stays. */
  --warn-bg: #c2410c;
  --warn-hover: #a83a0a;
  --warn-active: #8f3209;
  --warn-border: #8f3209;
  --secondary-bg: #262a30;
  --secondary-hover: #2e333a;
  --secondary-active: #21242a;
  --secondary-border: #3d434b;
  --danger-text: #ff8a80;
  --disabled-bg: #2a2e34;
  --disabled-fg: #7a818b;
  /* Measured against the card: 7.66:1, and 8.53:1 against the page. */
  --focus-ring: #b0b4ba;

  /* --- The light scheme, removed 2026-10-03 ----------------------------------------------
     The page carried two schemes until now: these were the light values, and
     `@media (prefers-color-scheme: dark)` overrode them with the ones above. They were taken
     out deliberately rather than lost, and the last light palette is kept here in full, so a
     future attempt can revise it instead of reinventing it. Commit 3e975ed is the last one to
     carry it, the whole file included.

       --bg: #ffffff                    --fg: #111111
       --fg-soft: #333333               --muted: #555555
       --card-bg: #e9ecf1               --card-border: #d5dae1
       --drop-bg: #dfe3ea               --drop-border: #6b7280
       --accent: #0066cc                --accent-soft: #e2f0ff
       --accent-fill: #0066cc           --accent-fill-hover: #0057ad
       --accent-fill-active: #00499a    --accent-fill-border: #00499a
       --danger-bg: #cc3333             --danger-hover: #b32d2d
       --danger-active: #9c2727         --danger-border: #9c2727
       --warn-bg: #c2410c               --warn-hover: #a83a0a
       --warn-active: #8f3209           --warn-border: #8f3209
       --secondary-bg: #ffffff          --secondary-hover: #f1f3f6
       --secondary-active: #e6eaef      --secondary-border: #ccd2d9
       --danger-text: #b3261e           --disabled-bg: #ebebeb
       --disabled-fg: #7a7a7a           --focus-ring: #6b6b6b

     It was built three times and rejected three times. Why each failed is worth more than the
     values above:

     1. The original - #f7f7f7 cards on a #ffffff page. Those two are 1.07:1 apart, which is
        not a design, it is a rounding error: nothing separated a card from the page, 1px
        borders carried the whole layout, and the page read as a wireframe.
     2. An inversion - a #eceff3 page with white cards. Rejected because it puts the bright
        field *outside* the content instead of inside it; the page reads inside out.
     3. The conventional direction, done deliberately - white page, visibly grey #e9ecf1
        cards, #dfe3ea well, #6b7280 drop border. Rejected as "horrible".

     Three arrangements were also tried and rejected while building the third, so they are not
     worth re-trying: wells cut through to the page, which makes the drop zone a mid-grey slab;
     white wells, the prettiest drop zone of the three, but the list loses its region and the
     border ends up carrying the structure again; and mid-grey inset wells, where the drop zone
     becomes the heaviest thing on the card.

     Three things about it are not a matter of taste, and are worth measuring before anything
     else is drawn:

     - The button fills were never the problem. #0066cc under white text is 5.57:1, and it
       stopped looking loud once the surfaces around it had real contrast.
     - The drop zone's dashed border has to clear 3:1 against its own fill, and that is a
       measurement rather than a standard to restate. The original #999999 did not - 2.46:1
       against the #eeeeee it sat on - and the surviving dark scheme's own #565b62 did not
       either, at 2.19:1 against its #24272d; it was raised to #7a818b on 2026-10-03 and now
       measures 3.76:1.
     - A card has to be a visible step from its page. 1.07:1 is not a step.
     -------------------------------------------------------------------------------------- */

  /* Spacing, on a 4px base. The page uses the first five; the sixth is the scale's top step,
     for the page frame that is left in pixels rather than a component that needs air. */
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-5: 24px;
  --space-6: 32px;

  /* Type. The rem values are the pixels named beside them at the default 16px root. */
  --text-xs: 0.8125rem;    /* 13px - the stream URL */
  --text-sm: 0.875rem;     /* 14px - hints and fine print */
  --text-md: 1rem;         /* 16px - body text and controls */
  --text-lg: 1.125rem;     /* 18px - card headings */
  --text-xl: 1.35rem;      /* 21.6px - the playing title */
  --text-2xl: 1.5rem;      /* 24px - the page title */
  /* Two of Inter's three vendored weights are set anywhere: controls are 500, and 600 is the
     emphasis for headings and the playing title. Everything else is the roman 400. */
  --weight-medium: 500;
  --weight-semibold: 600;

  --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
  --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  /* Lucide's icons are drawn for a 24px box; this is the size they are shown at. */
  --icon-size: 1.1rem;
}

/* The page's own width has to change with the layout, and this is easy to miss because the
   rule is already there and looks unrelated. `body` carried `max-width: 600px` from when this
   was one centred column. Two columns inside 600px gives each card a 292px track, which is
   250px of content box once the card's own border and padding come off, while the player's
   transport row needs 333.63px on one line, measured end to end - adding up the five buttons
   alone omits the four 8px gaps between them and undercounts by 32px. It cannot fit, and the
   numbers are not close: rendered at 1440, each card's content box is 450px against that
   333.63px. (Measured in the browser at HEAD. The first version of this comment had the card
   and the row both wrong - 230px and 260px - and an earlier correction of it put the row at
   310px by leaving the gaps out a second time.) */
body { background: var(--bg); color: var(--fg); font-family: var(--font-sans); max-width: 1000px; margin: 40px auto; padding: 20px; line-height: 1.5; }

/* The browser's own heading defaults - 1.5em and 1.17em with a lump of margin above and
   below - were doing the page's typography until now. These two rules replace them with the
   scale, and with no top margin: every heading here is the first thing in its container, and
   the container's own padding is what gives it its distance from the top edge. */
h2 { font-size: var(--text-2xl); font-weight: var(--weight-semibold); line-height: 1.25; letter-spacing: -0.011em; margin: 0 0 var(--space-4); }
h3 { font-size: var(--text-lg); font-weight: var(--weight-semibold); line-height: 1.3; margin: 0 0 var(--space-3); }

/* The browser's default link blue is close to unreadable against this background. */
a { color: var(--accent); }
.card { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: 8px; padding: 20px; margin-bottom: 20px; }

/* `font: inherit` is not decoration: it is what carries Inter, and the body's 16px with it,
   into every control, which the browser's own button font would otherwise override. The
   border is coloured rather than transparent now, but what keeps every button the size it was
   designed at is the *width*: 1px has been in every button's box model since the quiet variant
   needed somewhere to put its own border, so this recolours that space rather than adding to
   it, and `.secondary` / `.danger` / `.warn` only ever change the colour too. The transition
   here covers a button's own state change on the three properties that state is read from -
   150ms, border-color included, so a rim changes with the fill under the pointer. It is not
   the page's only motion: the toast and the drop zone each carry a transition of their own
   further down, the reduced-motion query lists all three, and the drop zone's is the reason
   it appears in that query. */
button {
  font: inherit;
  font-weight: var(--weight-medium);
  background: var(--accent-fill);
  color: white;
  border: 1px solid var(--accent-fill-border);
  padding: var(--space-2) var(--space-4);
  border-radius: 6px;
  cursor: pointer;
  transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
/* The spacing between two adjacent buttons. A row that holds buttons either sets no column gap
   at all (the playlist's actions, the modal's) and gets its spacing from here, or sets one and
   zeroes this margin out (the transport, whose wrapped second line would otherwise start
   indented by it). One of the two mechanisms, never the two stacked. */
button + button { margin-left: var(--space-2); }
button:hover:not(:disabled) { background: var(--accent-fill-hover); }
button:active:not(:disabled) { background: var(--accent-fill-active); }
button.danger { background: var(--danger-bg); border-color: var(--danger-border); }
button.danger:hover:not(:disabled) { background: var(--danger-hover); }
button.danger:active:not(:disabled) { background: var(--danger-active); }
button.warn { background: var(--warn-bg); border-color: var(--warn-border); }
button.warn:hover:not(:disabled) { background: var(--warn-hover); }
button.warn:active:not(:disabled) { background: var(--warn-active); }
/* The quiet variant. Three blue buttons of equal weight - Connect, Copy, Sort by name - left
   the page with no hierarchy at all; this is what the two utility ones wear instead. It is a
   surface with a hairline border rather than a colour, so it sits behind the accent without
   competing with it - and the hairline is the one the three filled variants now copy, each in
   its own colour, which is what took the flatness off them. */
button.secondary { background: var(--secondary-bg); color: var(--fg); border-color: var(--secondary-border); }
button.secondary:hover:not(:disabled) { background: var(--secondary-hover); }
button.secondary:active:not(:disabled) { background: var(--secondary-active); }
/* Keyboard focus only - `:focus-visible` leaves the mouse alone, which is why clicking a
   button no longer leaves a ring behind it. The ring is neutral rather than the accent: it
   has to read as focus on a button that may itself be accent blue, red or orange, and a
   blue ring on a blue button says less than a grey one does. The text fields carry the same
   neutral ring - see `.stream-address input:focus` and `.login-card input:focus` - and so does
   the drop zone below, which is the one control on the page that is not a <button>. Links keep
   the accent: a link is not a control and its ring can only ever land on the page's own
   surface, never on a surface it must contrast with. */
button:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
/* The drop zone is a div carrying role="button" and tabindex="0" - that pair is what puts it in
   the tab order, and it is the only route to the file picker for a keyboard user, since the
   input itself is `hidden` and so focusable by nothing. A div does not match the selector
   above, so without this it takes no ring at all. */
.drop-zone:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Last on purpose: a disabled button is grey whatever variant it carries. It is a designed
   state rather than a translucency - a distinct surface with a legible dimmed foreground -
   because `opacity` on the whole button is what used to make the move strip's two arrow
   glyphs (U+2B06 and U+2B07, which the browser draws from an emoji font) nearly disappear at
   40% while the two triangle glyphs beside them, drawn from the page's own font, survived it.
   The icons are Lucide now and carry no glyph of their own; they follow `color`, so they dim
   by the same amount the label of a disabled text button does. */
button:disabled {
  background: var(--disabled-bg);
  color: var(--disabled-fg);
  border-color: transparent;
  cursor: not-allowed;
}
#status { font-weight: var(--weight-semibold); margin-top: var(--space-3); }
.device { margin: 0 0 var(--space-5); color: var(--fg-soft); font-size: var(--text-sm); }
.hint { color: var(--muted); font-size: var(--text-sm); margin: 0 0 var(--space-2); }
.hint:last-child { margin-bottom: 0; }

/* The stream address and its Copy button. min-width:0 is what lets a long URL shrink and
   scroll inside the row, rather than pushing the button off the edge of the card. */
.stream-address { display: flex; gap: var(--space-2); margin: 0 0 var(--space-2); }
.stream-address:last-child { margin-bottom: 0; }
.stream-address input {
  flex: 1 1 auto;
  min-width: 0;
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--card-border);
  border-radius: 6px;
  background: var(--bg);
  color: var(--fg);
  font-family: var(--font-mono);
  font-size: var(--text-xs);
}
/* Neutral, like the buttons': the ring's job is to say where the keyboard is, and that is the
   same message on a field as on a button. The 1px offset is a choice rather than a constraint -
   the card's own 20px padding leaves room for the 4px a 2px offset would draw, and nothing
   else is within reach of it either; the tighter offset just keeps the ring hugging the field
   it belongs to. An earlier version of this comment justified it by a collision with the
   card's edge that does not exist. */
.stream-address input:focus { outline: 2px solid var(--focus-ring); outline-offset: 1px; }

.drop-zone {
  border: 2px dashed var(--drop-border);
  border-radius: 6px;
  padding: var(--space-5);
  text-align: center;
  background: var(--drop-bg);
  color: var(--muted);
  transition: background-color 0.15s ease, border-color 0.15s ease;
  cursor: pointer;
}
.drop-zone.dragover {
  background: var(--accent-soft);
  border-color: var(--accent);
  color: var(--accent);
}
.file-selection-info {
  font-size: var(--text-sm);
  margin-top: var(--space-1);
  color: var(--fg-soft);
}

/* Transient notice, bottom centre. Fades in and out rather than appearing instantly, and
   is never clickable - it must not sit between the pointer and the controls. Its colours
   are literals rather than tokens, deliberately: a notice says the same thing whatever the
   page around it is doing, so it is not part of the palette. */
.toast {
  position: fixed;
  left: 50%;
  bottom: var(--space-5);
  transform: translateX(-50%) translateY(8px);
  max-width: min(90vw, 560px);
  padding: var(--space-3) var(--space-4);
  border-radius: 8px;
  background: #333;
  color: #fff;
  font-size: var(--text-sm);
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.18s ease, transform 0.18s ease;
  z-index: 10;
}
.toast.visible { opacity: 1; transform: translateX(-50%) translateY(0); }
.toast.ok { background: #1b7f3b; }
.toast.error { background: #b3261e; }

@media (prefers-reduced-motion: reduce) {
  .toast { transition: none; }
  .drop-zone { transition: none; }
  button { transition: none; }
}

/* The sign-in form. It is the only place with ordinary text inputs - the index page's
   only one is .stream-address input, which is styled for its row - so these rules are
   scoped to .login-card rather than applied to every input on the site. */

/* The sign-in page is one narrow column, centred on the screen both ways. The body's 1000px
   is sized for the app's two-column layout and would leave this page's heading and card
   stranded against the left edge of it - a 360px card at the left of a 1000px column reads
   as a layout mistake, which it was.
   The width goes on the body rather than the card so the heading and the card share one
   edge; put on the card alone, the heading would stay at the far left and the two would look
   unrelated. Centring is this rule's own flex column, plus `margin: 0 auto` - the base body
   rule's `margin: 40px auto` is for a document that flows from the top, and its 40px of top
   margin would undo the vertical centring.
   border-box so `max-width` and `min-height` mean the whole box, padding included: 400 less
   its two 20px gutters is the 360 the card gets, and 100vh does not become 100vh + 40px and
   grow a scrollbar. That matters more here than usual because `.card` is content-box - its
   20px padding and 1px border would otherwise push 42px past the column. */
body.login-page {
  box-sizing: border-box;
  max-width: 400px;
  min-height: 100vh;
  margin: 0 auto;
  display: flex;
  flex-direction: column;
  justify-content: center;
}
body.login-page h2 { text-align: center; }
/* `.card`'s own 20px bottom margin would sit below the last item and lift the pair 10px
   above the true centre of the viewport. */
.login-card { box-sizing: border-box; margin-bottom: 0; }
.login-card label { display: block; margin: 0 0 var(--space-1); color: var(--fg-soft); font-size: var(--text-sm); }
.login-card input {
  display: block;
  width: 100%;
  box-sizing: border-box;
  margin: 0 0 var(--space-3);
  padding: var(--space-2) var(--space-3);
  font-size: var(--text-md);
  color: var(--fg);
  background: var(--bg);
  border: 1px solid var(--card-border);
  border-radius: 6px;
}
.login-card input:focus { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
.login-card button { width: 100%; }
.login-error { color: var(--danger-text); font-size: var(--text-sm); margin: 0 0 var(--space-3); }

/* --- Layout -------------------------------------------------------------
   Two columns: the playlist on the left, the three original cards stacked on the right.

   `align-items: stretch` makes the left card match whichever column is taller, which for a
   short list is the right one - that is the "same height as the other three" this was asked
   for. It is NOT what bounds the list: a grid row is sized by its tallest item, so once the
   list is long enough the left column *becomes* the taller one, the row grows, and the card
   grows with it. The bound is `max-height` on the list itself, further down. */
.layout {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: var(--space-4);
  align-items: stretch;
}
.side { display: flex; flex-direction: column; gap: var(--space-4); }

.playlist-card {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  /* Size containment is what actually makes the card take the row's height. The comment here
     used to credit `min-height: 0` with it, and claimed the card "takes the row's height
     instead of forcing the row to take the card's" - which is what the code did NOT do.
     `min-height: 0` removes a floor; it is what lets the card be *shrunk*, and on its own it
     bounds nothing. The row still sized itself to the card's own max-content, so a long list
     grew the card and dragged the right column up with it.
     Measured with 91 rows at 1440x1000: card 653 -> 825, right column 673 -> 845 because the
     row had grown under both, page 1000 -> 1056, and the list only began to scroll once all
     of that had happened. With containment the card contributes no height to the row at all,
     so the row is the right column's height, the card stretches to it, and the list scrolls
     from the card's default size rather than from 60vh: card 575, right column 595, page
     1000, list 350 of 3002.
     `contain: size` contains the *width* too, which is safe only because `.layout`'s tracks
     are `minmax(0, 1fr)` and set it - the same floor that keeps a long filename from widening
     the card. It is undone in the one-column layout, where see the note there. */
  contain: size;
  min-height: 0;
  /* The horizontal half of the same idea, and the same rule at one more link of the chain
     explained at .playlist-listwrap. */
  min-width: 0;
}
/* This card is a flex column, so its `gap` is the rhythm. The children's own bottom margins
   would be added to that gap rather than collapsing with it - which is what the drop zone's
   15px used to do - so they are dropped here and the gap is left to speak. */
.playlist-card > * { margin-bottom: 0; }
.playlist-body {
  display: flex;
  gap: var(--space-2);
  /* `min-height: 0` is the standard fix for a flex item refusing to shrink below its
     content, and it is correct here - but it only makes shrinking *possible*. What decides
     whether the list ever has to scroll is whether anything gives it a height to overflow,
     and that is the `max-height` on `.playlist-list` below, plus this rule letting the
     wrapper pass the row's height down. Remove the max-height and the whole chain still
     looks right while the page grows instead. */
  flex: 1;
  min-height: 0;
  /* See .playlist-listwrap. */
  min-width: 0;
  overflow: hidden;
}
/* The bordered box is the WRAPPER, not the list. The empty message lives inside the list's
   frame - in place of it, not beside it - and a border on the <ul> would collapse to nothing
   when there are no rows while the message floated outside it, unstyled. */
.playlist-listwrap {
  flex: 1;
  min-height: 0;
  /* The line that was missing, and the one link of the horizontal chain whose absence does
     damage on its own. A flex item's automatic minimum size is its min-content width, and
     for this item that is the longest name in the list: left at `auto`, the frame refuses to
     shrink below the full width of its text, pushes the move strip off the right edge, and
     leaves `text-overflow: ellipsis` with nothing to do because the frame has already grown
     to fit the name. Measured at 820px with a 126-character name: a 934px frame inside a
     366px card, the move buttons starting at x=983 on an 802px viewport, and the name neither
     ellipsised nor readable. The same declaration is carried at the other links
     (.playlist-card, .playlist-body, .playlist-list) so the floor cannot re-enter through a
     change to the grid track or a flex direction; measured, this one is the one doing the
     work today. */
  min-width: 0;
  display: flex;
  flex-direction: column;
  border: 1px solid var(--card-border);
  border-radius: 4px;
  background: var(--drop-bg);
}
.playlist-list {
  flex: 1;
  min-height: 0;
  /* See .playlist-listwrap. */
  min-width: 0;
  overflow-y: auto;
  /* The bound, and the one line the flex chain cannot supply. min-height: 0 removes a FLOOR -
     it is what lets a flex item shrink below its content. It imposes no ceiling, and a scroll
     container still contributes its content height to its ancestors' intrinsic sizes. The grid
     row here is auto, so it sizes to its tallest item's max-content: without this line a long
     list makes the row taller, the card stretches to match, and the list is never asked to
     overflow. The page grows, which is the thing the rest of this chain exists to prevent.
     Writing it inside the narrow-screen query only, as an earlier draft did, leaves the wide
     layout - the one with room for a long list - as the case that breaks. */
  max-height: 60vh;
  list-style: none;
  margin: 0;
  padding: 0;
}
/* order: -1 puts the message at the TOP of the frame. The list is flex: 1 and takes the
   wrapper's free space, so a sibling after it renders at the frame's bottom edge under a blank
   scroll area, not where the first row would be. */
.playlist-empty { order: -1; padding: var(--space-3) var(--space-2); opacity: 0.6; }
/* The strip is neutral: its four buttons carry the quiet `.secondary` variant in the markup,
   so none of them is accent blue in any state - they are a grey surface at rest, a lighter one
   on hover and a darker one on press, and `button:disabled` still greys them out when there is
   nothing to move. The four keep their own hairline, which comes from `.secondary` and is
   load-bearing - it is what the other variants' rims were copied from.
   The x is left carrying `.danger`: one destructive control in the column, and it should look
   like one. It wears the same rim Clear does, because it is the same variant - `.danger` - and
   there is no reason for one of them to be flat. It was exempted for one pass, when the strip
   as a whole was meant to be left out of the border change, and the rule that did that - a
   `.move-strip button.danger { border-color: transparent }` - was deleted when the exemption
   turned out to be the out-of-date thing rather than the rim. The four quiet buttons beside it
   never needed such a rule: their hairline comes from `.secondary`. This paragraph used to
   argue the opposite, that the x kept a transparent border so as not to stand out from the
   four; that was true for one commit and has not been since. It also used to warn that the
   focus ring was still the accent, which would have put a blue edge in a grey column - the ring
   is neutral now, so there is no such edge. */
.move-strip {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  /* The buttons sit flush against the body's edges, and .playlist-body clips (overflow:
     hidden). A focus ring is drawn 4px outside its button - 2px of offset plus 2px of ring -
     so without this the top and right of the ring on the first and last buttons would be cut
     off. 4px on the three sides that touch the clip, none on the left where the list's gap
     already leaves room. */
  padding: var(--space-1) var(--space-1) var(--space-1) 0;
}
/* `button + button` is a horizontal sibling rule - "put 8px between two buttons side by side" -
   and there are no two buttons side by side in here. Left on, it did more than put a margin on
   the wrong axis: it applied to the three buttons that have another button before them and not
   to #moveUp (nothing precedes it) or #removeSelected (a <span> does), and the column's
   default `align-items: stretch` then stretched exactly those two to the container's full
   width. Measured before this rule: 46.8, 38.8, 38.8, 38.8, 46.8 - two widths, neither of them
   the one the buttons are designed at. With it, all five are 38.8 wide and nothing in the
   column is stretched, because every item's margin box is now its own width. */
.move-strip button + button { margin-left: 0; }

.strip-gap { flex: 1; }
.playlist-actions { display: flex; }

/* --- Rows --------------------------------------------------------------- */
.playlist-list li {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-1) var(--space-2);
  cursor: default;
  /* This line does not stop a long name widening the column - the comment that used to sit
     here said it did, and that is the claim the rendered page disproved. The row is a flex
     container, and its min-content width is still the whole nowrap name, which the column
     above then sizes itself to. What makes a long name clip is this same declaration carried
     up the chain - see .playlist-listwrap. */
  min-width: 0;
  border-bottom: 1px solid var(--card-border);
}
.playlist-list li:last-child { border-bottom: none; }
.playlist-list li .name {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.playlist-list li.selected { background: var(--accent-soft); }
.playlist-list li.playing .name { font-weight: var(--weight-semibold); }
.playlist-list li.playing::before { content: "\25B8"; }
.playlist-list li:not(.playing)::before { content: "\00A0"; }
.playlist-list li.missing .name { text-decoration: line-through; opacity: 0.6; }
.playlist-list li.dragging { opacity: 0.4; }
/* The marker marks the side the group will land on, which is the side the pointer is on: the
   drag handler sets data-drop-after from the pointer's position within the row, and inserts
   after the row when it is "1". A single inset shadow at the top drew the line above the row in
   both cases, so a drop in the lower half put the group one place lower than the line it aimed
   at. The attribute selector is more specific than the bare .drop-target rule, so this wins
   whenever it is set. */
.playlist-list li.drop-target { box-shadow: inset 0 2px 0 var(--accent); }
.playlist-list li.drop-target[data-drop-after="1"] { box-shadow: inset 0 -2px 0 var(--accent); }

/* --- Transport and icons ------------------------------------------------ */
/* `flex-wrap: wrap` is what keeps the row inside a phone-width card. Measured at HEAD, the row
   needs 333.63px on one line - 4 x 42px media buttons, 4 x 8px gaps and a 133.63px Panic /
   Reset, the span from #prevBtn's left edge to #panicBtn's right one, so the gaps are counted
   and not just the buttons. With this rule stripped on a 320px layout viewport, the card's
   content box is 223px and the row does not overflow it: a flex row given `nowrap` shrinks its
   items instead, so the four squares come down from 42px to 24.95px and Panic / Reset from
   133.63px to 91.17px, less than its own label needs, in a row that stays 223px wide - the
   container's own width. Nothing scrolls (scrollWidth 305 against clientWidth 305, #panicBtn's
   right edge at 264 inside the 305px viewport), so what the wrap prevents is the controls
   losing their shape, not the page gaining a scrollbar. Wrapping moves Panic / Reset to a
   second line there instead, at its designed size. It bites only where it has to: at 1440 the
   row needs 333.63px inside a 450px card and stays on one line, and at 390 it still needs one
   line it does not have - 333.63px in a 293px box - so it wraps there too.
   The 321px and the 39px squares that used to stand in for these figures were correct once:
   `.transport button.icon` had no size rule, so the four buttons were the shared 38.78px
   (4 x 38.78 + 32 + 133.63 = 321.2), and `ea8d82a` restored their 42px without re-running the
   sum. The way the old comment said the row failed was wrong too: stripping the wrap squashes
   the buttons, it does not scroll the document. */
.transport { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3) var(--space-2); margin-top: var(--space-2); }
/* The column gap, not the adjacency margin, spaces this row's buttons: the margin would be
   kept by the first button of a wrapped second line, which indents it against the row above
   it by exactly the margin. Both are 8px, so the row is the same width either way - 333.63px,
   measured - and only the wrapped case differs. */
.transport button + button { margin-left: 0; }
/* An icon button is sized by its padding and its icon, not by a width: 5.6px and 9.6px around
   a 17.6px icon, so it comes out at 38.78 x 30.78px - the "about 39x31" the rest of this file
   rounds it to, and the size the move strip's five buttons are designed at. `line-height: 1`
   keeps the button from being taller than the icon it holds. The transport's four are the
   larger of the page's two icon buttons: they are overridden to a stated 42 x 42 in the rule
   below, which is the height of the text buttons beside them (Panic / Reset measures 42px), so
   the row and the four icons in it line up. */
button.icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0.35rem 0.6rem;
  line-height: 1;
}
/* Every icon is drawn in the button's own colour - `currentColor` on both sides - which is what
   lets an icon dim when its button is disabled, since a disabled button changes its `color`
   and nothing else. The default here is Lucide's: no fill, a 2px stroke. */
button.icon svg { width: var(--icon-size); height: var(--icon-size); display: block; fill: none; stroke: currentColor; }
/* The transport is the larger of the page's two icon buttons and the move strip is the
   smaller, so the size lives on the one zone that wants it rather than on `.icon`, which is
   shared. The icon scales with the button through the same `--icon-size` the rule above
   reads - a custom property inherits, so setting it here resizes the svg inside without a
   second width rule - and that holds for the solid media shapes as well as the stroked
   chevrons, since `.solid` only changes fill and stroke, never size. */
.transport button.icon {
  --icon-size: 1.25rem;
  width: 2.625rem;
  height: 2.625rem;
  padding: 0;
}
/* The four media controls are the page's own solid shapes, not Lucide icons - a filled
   triangle is not something the stroke set has. Said twice on purpose: `solid` on the svg
   here, and fill/stroke on the symbols in icons.svg, so neither end depends on which
   declaration happens to win. */
button.icon svg.solid { fill: currentColor; stroke: none; }
.drop-zone.busy { opacity: 0.5; pointer-events: none; }
/* One line, ellipsised, by the same rules the playlist rows use. It used to carry
   `overflow-wrap: anywhere`, which meant a long filename wrapped instead of clipping - and the
   card grew a line at a time with it, so the transport and the Panic button slid down the page
   in proportion to how long somebody's filename happened to be. A name is a label here, not
   content: if it does not fit, the interesting part is its beginning and the rest is not worth
   a second line of the player card.
   `white-space: nowrap` makes the name one unbreakable minimum width. Unlike a playlist row,
   there is no `min-width: 0` chain under this one to stop that width reaching the column - a
   block inside a block has no automatic minimum to opt out of - so the thing that holds it is
   `.layout`'s tracks being `minmax(0, 1fr)`. Measured with them forced to plain `1fr 1fr` and a
   140-character title: the player card blows out to 2286.06px, the document scrolls to
   scrollWidth 2557 against clientWidth 1425, and the playlist card beside it is crushed to its
   42px border-and-padding box. The playlist is not the half at risk there. Forced the same way
   with a 140-character name in a row, its tracks do not move at all - 492px each, the same as
   with a short name - because the chain explained at .playlist-listwrap is what stops that name
   having a say. An earlier version of this comment said removing the tracks "would break this
   and the playlist together", which gave the tracks credit for the playlist's own protection. */
.player-title {
  font-size: var(--text-xl);
  font-weight: var(--weight-semibold);
  margin-top: var(--space-1);
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.player-position { color: var(--muted); font-size: var(--text-sm); }

/* --- Confirmation ------------------------------------------------------- */
.modal {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 20;
}
.modal[hidden] { display: none; }
.modal-box {
  background: var(--card-bg);
  color: inherit;
  border-radius: 6px;
  padding: var(--space-4) var(--space-5);
  max-width: 24rem;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.modal-actions { display: flex; justify-content: flex-end; margin-top: var(--space-4); }

/* Stacked below 50rem, player first and playlist beneath it.
   50rem, not 40: at a 640px viewport a two-column layout gives each card a 284.5px track and
   242.5px of content box, while the transport row needs 333.63px on one line, end to end, so
   a 40rem breakpoint hands the two-column layout a width it cannot hold - the row wraps to a
   second line there. It degrades rather than breaks, but it degrades in the band where most
   laptop windows sit. (Both numbers measured at a 640px viewport with the
   two-column layout forced on, since the query above hides it.) */
@media (max-width: 50rem) {
  .layout { grid-template-columns: minmax(0, 1fr); }
  .side { order: 1; }
  /* Containment is undone here, and must be: in one column the card is the row's only occupant,
     so containing its size would leave the row with nothing to size to and collapse the card.
     There is no neighbouring column to take the height from. The list's own 50vh cap below is
     what bounds it in this layout, which is the job containment does in the other one. */
  .playlist-card { order: 2; contain: none; }
  .playlist-list { max-height: 50vh; }
}
