/* ShuffleTube — theme, layout, iOS-style switch, floating mini player. */

:root {
  --bg: #f6f6f7;
  --surface: #ffffff;
  --surface-2: #ececf0;
  --text: #0f0f0f;
  --text-dim: #606067;
  --border: #dcdce2;
  --scroll-thumb: #c2c2cb;   /* quiet, but still readable as a scrollbar */
  --accent: #cc0000;
  /* A wash of the accent, for the one field on the page that has to look
     like it is waiting to be typed into. Faint enough to stay behind the
     text; the stronger one marks the fixed part of the field. */
  --accent-wash: color-mix(in srgb, var(--accent) 5%, var(--surface));
  --accent-wash-2: color-mix(in srgb, var(--accent) 11%, var(--surface));
  --error: #cc0000;
  --switch-off: #d8d8de;
  --switch-on: #34c759;      /* the familiar iOS green */
  --shadow: 0 10px 30px rgba(0, 0, 0, .16);
  --radius: 14px;
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #0f0f0f;
    --surface: #1c1c1f;
    --surface-2: #27272b;
    --text: #f2f2f4;
    --text-dim: #9b9ba3;
    --border: #34343a;
    --scroll-thumb: #4c4c55;
    /* The same wash needs more of the accent to show on a dark ground. */
    --accent-wash: color-mix(in srgb, var(--accent) 9%, var(--surface));
    --accent-wash-2: color-mix(in srgb, var(--accent) 16%, var(--surface));
    --error: #ff6b6b;          /* the accent red is too dim on this ground */
    --switch-off: #48484e;
    --shadow: 0 12px 34px rgba(0, 0, 0, .55);
  }
}

* { box-sizing: border-box; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto,
        "Noto Sans TC", "PingFang TC", "Microsoft JhengHei", sans-serif;
  -webkit-font-smoothing: antialiased;
}

/* The page takes the window, minus a 20px margin on each side: the width it
   gains all goes to the player, which is the thing you look at. */
.wrap { margin: 0 auto; padding: 20px 20px 72px; }

/* ------------------------------------------------------------------ header */

/*
 * The masthead carries the import row as well as the name, because every line
 * of chrome above the player is height the video does not get. On a narrow
 * screen the row has nowhere to be but its own line, and wraps to one.
 */
.top {
  display: flex; align-items: center; flex-wrap: wrap; gap: 10px 16px;
  padding: 2px 0 6px;
}

/* Mark, then name over tag — the arrangement a site is recognised by. */
.brand { display: flex; align-items: center; flex: 1 1 0; gap: 10px; min-width: 0; }
.brand__logo { flex: none; color: var(--accent); }
.brand__text { min-width: 0; }
.brand__name { margin: 0; font-size: 21px; font-weight: 800; letter-spacing: -.02em; line-height: 1.15; }
.brand__name span { color: var(--accent); }
.brand__tag { margin: 0; color: var(--text-dim); font-size: 12.5px; line-height: 1.3; }
/* Beside the language switch at every width: the three are about the site
   itself, and the row rides the masthead's right end however it wraps. The
   first of them carries the gap that pushes the row over; the rest sit tight. */
#site-sponsor { margin-left: auto; }
#site-sponsor ~ #site-share, #site-sponsor ~ #lang-toggle { margin-left: 0; }

/* Wide, the status stands beside the import button, in the track after the
   field. Narrow, the wrapper steps out of the way and the status takes a line
   of its own under the field it reports on. */
.top__end { display: contents; }
.status { order: 4; flex: 1 1 100%; margin: 0; font-size: 13px; color: var(--text-dim); }

.intake { flex: 1 1 100%; order: 3; min-width: 0; }

/*
 * With room for it, the import row moves up onto the masthead, right after
 * the name: it is the one thing on the page you come to type in. It used to
 * sit in the centre of the page, between two equal tracks, but the track on
 * the right also has to hold the status and the three site buttons, and the
 * status was folding into two or three lines beside an empty stretch on the
 * left. So the name's track is only as wide as the name, and what is left is
 * shared between the field and the status: seven parts to five, which at a
 * laptop's width is about the 46% the field used to take, and the status gets
 * a line to itself. Above 1800px the field stops growing at 860px, as it
 * always did, and the rest is the status's.
 *
 * The desktop rule is its own block below, after the shared one: sideways on
 * a phone the equal tracks stay, since there the tag under the name is put
 * away and the name is not what is crowding the row.
 */
/*
 * The second condition is a phone held sideways. There the stacked masthead —
 * name over tag, the import row on a line of its own, the formats it takes
 * under that — runs to 155px of a 393px window, and the video left under it
 * comes out smaller than the same phone draws upright, which is the opposite
 * of what turning it sideways was for. The row moves up beside the name at a
 * good deal less width than a desktop needs, so a short window asks for it
 * early; 640px is where the field still has room to be typed in.
 */
@media (min-width: 901px), (max-height: 600px) and (min-width: 640px) {
  .top {
    display: grid; align-items: center;
    grid-template-columns: 1fr minmax(0, min(860px, 46%)) 1fr;
  }
  .intake { order: 0; width: 100%; }
  .top__end { display: flex; align-items: center; gap: 12px; min-width: 0; }
  .status { order: 0; flex: 1 1 auto; min-width: 0; }
}
@media (min-width: 901px) {
  .top { grid-template-columns: auto minmax(0, 1.4fr) minmax(0, 1fr); }
}
@media (min-width: 1800px) {
  .top { grid-template-columns: auto minmax(0, 860px) minmax(0, 1fr); }
}

/* The placeholder says what the label says, and the label stays in the markup,
   so a screen reader still has the field's name. Sideways it goes at any
   width: a line of chrome the video pays for, saying what the field below it
   already says. */
@media (min-width: 901px), (max-height: 600px) {
  .intake .lbl {
    position: absolute; width: 1px; height: 1px; margin: -1px;
    padding: 0; border: 0; overflow: hidden; clip-path: inset(50%);
    white-space: nowrap;
  }
}

/*
 * Sideways there is height for the masthead or for the video, not for both,
 * so the two lines that explain rather than do give theirs up: the tag under
 * the name, and the formats under the field. Both say what the page is for,
 * which is a first-visit thing to read, and turning the phone sideways is not
 * a first visit. Everything you act on stays.
 */
@media (max-height: 600px) {
  .brand__tag, .intake .hint { display: none; }
  /* Sideways on a phone the masthead's right track is about 160px, room for
     the language word and two glyphs, not for either glyph's label. */
  .top__end { gap: 8px; }
  #site-sponsor, #site-share { padding: 6px 9px; }
  #site-sponsor span, #site-share span { display: none; }
  /* What is left of the masthead sideways is what the status has to wrap
     inside, and a long report ran to four rows of it. Two is enough to read
     what happened; the rest is there again upright. */
  .status {
    display: -webkit-box; -webkit-box-orient: vertical;
    -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden;
  }
}

.linkish {
  display: inline-flex; align-items: center; gap: 6px;
  background: none; border: 1px solid var(--border); color: var(--text);
  padding: 6px 12px; border-radius: 999px; cursor: pointer; font-size: 13px;
  white-space: nowrap;
}
.linkish:hover { background: var(--surface-2); }
.linkish .icon { width: 15px; height: 15px; flex: none; }
.btn .icon { width: 17px; height: 17px; flex: none; }

/* ------------------------------------------------------------------- forms */

/* Never wraps: the button stays beside the field at every width. */
.form { display: flex; gap: 8px; flex-wrap: nowrap; align-items: stretch; }
.form input[type="text"] { flex: 1 1 auto; min-width: 0; }
label.lbl { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 5px; }

/* The coffee sheet's amounts and address are fields like any other: left to
   the browser, they came out white in the dark theme, and small enough for
   iOS to zoom in on (see the coarse-pointer rule further down). */
input[type="text"],
input[type="number"],
input[type="email"] {
  width: 100%; padding: 11px 14px; font-size: 15px; color: var(--text);
  background: var(--surface); border: 1px solid var(--border);
  border-radius: 10px;
}
input[type="text"]:focus-visible,
input[type="number"]:focus-visible,
input[type="email"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 7px;
  flex: none; padding: 11px 18px; font-size: 15px; font-weight: 600; cursor: pointer;
  color: #fff; background: var(--accent); border: none; border-radius: 10px;
  white-space: nowrap;
}
.btn:hover { filter: brightness(1.08); }

/* The masthead's right track holds the coffee button's label only from about
   1250px up (English is the wider of the two): below that, the three worded
   pills run past the track's end and the language switch off the page. The
   cup says it alone until then, and the accessible name stays on the button. */
@media (max-width: 1250px) {
  #site-sponsor { padding: 6px 9px; }
  #site-sponsor span { display: none; }
}

/* On a narrow screen the glyph carries the meaning and the label would push
   the field too small. The accessible name stays on the button either way. */
@media (max-width: 480px) {
  .btn { padding: 11px 14px; }
  .btn span { display: none; }
  /* Three pills beside the name is one too many for a phone: the share
     button keeps only its glyph too, and the name keeps its room. The
     language switch has no glyph, so its word stays. */
  #site-share { padding: 6px 9px; }
  #site-share span { display: none; }
}

.hint { color: var(--text-dim); font-size: 12px; margin: 8px 0 0; }

/* What the field accepts, tucked under the field: every pixel between the two
   is a pixel of chrome the player does not get. */
.intake .hint { margin-top: 2px; }

/* ------------------------------------------------ iOS-style random switch */

/* A setting row inside a panel. It trails the list it belongs to, so the rule
   goes above it rather than below. */
.randomrow {
  display: flex; align-items: center; gap: 12px;
  padding: 12px 2px 4px; margin-top: 10px;
  border-top: 1px solid var(--border);
}
.randomrow__text { flex: 1; min-width: 0; }
.randomrow__label { font-weight: 600; font-size: 13.5px; }
.randomrow__help { color: var(--text-dim); font-size: 11.5px; margin: 2px 0 0; line-height: 1.45; }

/* On a narrow panel the description gets squeezed into a thin column beside
   the controls, so give it the full width and drop the controls below it. */
/* Auto-sync is a supporter's (see perks-core.js); a visitor's library panel
   ends with the lists. */
.syncrow { display: none; }
html.perk-autoSync .syncrow { display: flex; }

@media (max-width: 560px) {
  .randomrow { flex-wrap: wrap; row-gap: 10px; }
  .randomrow__text { flex: 1 1 100%; }
  .randomrow .select { margin-left: auto; max-width: 100%; }
}

.switch {
  position: relative; flex: none;
  width: 51px; height: 31px; padding: 0;
  border: none; border-radius: 999px; cursor: pointer;
  background: var(--switch-off);
  transition: background-color .22s ease;
  -webkit-tap-highlight-color: transparent;
}
.switch::after {
  content: ""; position: absolute; top: 2px; left: 2px;
  width: 27px; height: 27px; border-radius: 50%;
  background: #fff; box-shadow: 0 2px 5px rgba(0, 0, 0, .3);
  transition: transform .22s cubic-bezier(.3, 1.5, .6, 1);
}
.switch[aria-checked="true"] { background: var(--switch-on); }
.switch[aria-checked="true"]::after { transform: translateX(20px); }
.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

@media (prefers-reduced-motion: reduce) {
  .switch, .switch::after { transition: none; }
}

/* ------------------------------------------------------------------ banner */

.banner {
  display: flex; align-items: center; gap: 12px; margin: 14px 0 0;
  padding: 11px 14px; font-size: 13.5px;
  background: var(--surface-2); border: 1px solid var(--border); border-radius: 10px;
}
.banner p { margin: 0; flex: 1; }

/* What a press far down the page has to say (app.js, toast): over the middle
   of the window, where the eye is, whatever has been scrolled. */
.toast {
  position: fixed; left: 50%; top: 50%; z-index: 100;
  transform: translate(-50%, -50%);
  width: max-content; max-width: min(420px, calc(100vw - 32px));
  padding: 14px 18px; border-radius: 12px;
  background: var(--surface); color: var(--text);
  border: 1px solid var(--border); box-shadow: var(--shadow);
  font-size: 14.5px; line-height: 1.5; text-align: center;
  cursor: pointer; animation: toast-in .18s ease-out;
}
.toast[data-tone="error"] { border-color: var(--error); }
@keyframes toast-in { from { opacity: 0; transform: translate(-50%, -46%); } }
@media (prefers-reduced-motion: reduce) { .toast { animation: none; } }

/* ------------------------------------------------------------------ layout */

/*
 * Three blocks, placed rather than nested: the player, the queue beside it,
 * and the library under the player.
 *
 * The queue earns the taller column because it is the one you read while
 * something is playing — what is on now, and what is next. The library is
 * settings you touch occasionally, so it sits below the fold of the player
 * rather than above the thing it feeds.
 *
 * The two are held at about two to one: wide enough that a row can say which
 * video it is on one line, and wide enough again that the player's box sits
 * closer to the shape of the video inside it — the width the queue takes is
 * width the player would only have drawn as letterboxing. The floor keeps the
 * ratio from squeezing the name to nothing on the windows where two columns is
 * more than there is to give — a share of the width instead, as before, and
 * never more than the 420px a row wants.
 */
.main {
  display: grid; gap: 22px; margin-top: 12px; align-items: start;
  grid-template-columns: minmax(0, 1.9fr) minmax(min(420px, 38%), 1fr);
  grid-template-areas:
    "player  queue"
    "library queue";
}
/* min-width: 0 on every item, since a grid item's default minimum is what
   its contents would need laid out on one line — and a list's name is set on
   one line (see .library__name). Left at auto, a name longer than the column
   widened the column to fit it, and the player, the queue and the page with
   it: a phone showing a mix, whose name runs to a whole sentence, was 620px
   wide on a 390px screen. With 0 the column keeps its size and the name is
   cut short with an ellipsis, which is what that rule is for. */
.player { grid-area: player; min-width: 0; }
.panel--library { grid-area: library; min-width: 0; }

.panel--queue { grid-area: queue; min-width: 0; }

/*
 * Levelling the two columns.
 *
 * The queue spans both rows, so stretching it ends it exactly where the
 * library below the player ends — but a spanning item's own content also
 * feeds the sizing of the rows it spans, and a long queue would push them
 * taller than the player and library need. `height: 0` withdraws it from that
 * sizing; `min-height: 100%` then fills whatever the other two settled on.
 * Its list takes the slack and scrolls.
 */
/*
 * Two columns need height as well as width. A phone held sideways is wide —
 * past 901px on a large one — and about 400px tall, and stacking the chrome,
 * the player and a column of queue into that leaves the player nothing: the
 * height it is given below is what is left of the window, and beside a queue
 * that reserve runs the whole window out. So the second column is asked for
 * both, and a short window keeps the single column whatever its width.
 */
@media (min-width: 901px) and (min-height: 601px) {
  .panel--queue {
    display: flex; flex-direction: column; overflow: hidden;
    align-self: stretch; height: 0; min-height: 100%;
  }
  .panel--queue .queue { flex: 1 1 auto; min-height: 0; }
}

/* One column: the queue still follows the player, and the library trails it. */
@media (max-width: 900px), (max-height: 600px) {
  .main {
    /* minmax(0, …), like the wide layout's first column: a bare 1fr is
       minmax(auto, 1fr), whose minimum is again the widest thing inside. */
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas: "player" "queue" "library";
  }
}

/* ------------------------------------------------------------------- stage */

/* Reserves the player's space so the page does not jump when it goes mini. */
/*
 * The box is the shape of the video and nothing else: 16:9, never wider. A box
 * wider than that is not a bigger player — it is the same video with black
 * down either side, which is the player filling in the difference, and a phone
 * held sideways or an iPad with its toolbar out is exactly where that used to
 * happen. So the height gives first: it takes what the window has left under
 * the chrome above, and the box runs on down the page rather than stopping
 * short to keep the library in view. Only when even that is not enough — a
 * short window, where 16:9 of the full column would not fit at all — does the
 * width give in turn, and the box centres in its column instead of spanning it.
 *
 * Both numbers are measured by app.js: --stage-top for what the chrome above
 * takes, which changes with the language, a wrapped status line or a banner,
 * and --window-h for the window itself, which is not 100svh — that is the
 * window with every browser bar showing, and sideways the bars are usually
 * gone. svh stays as the fallback for the frame before the first measurement.
 *
 * The floor is what stands between a short window and no player at all: the
 * room is a subtraction, and chrome deep enough takes it to zero — and the
 * stage, which is drawn inside this box, would have nowhere to draw. Under the
 * floor the page scrolls, which is the cheaper thing to give up. Three fifths
 * of the window is the line: below that the chrome has taken more of a phone
 * held sideways than the video it is there to introduce.
 */
#stage-slot {
  --stage-cap: max(calc(var(--window-h, 100svh) - var(--stage-top, 220px)), 60svh);
  position: relative; aspect-ratio: 16 / 9;
  width: min(100%, calc(var(--stage-cap) * 16 / 9));
  max-height: var(--stage-cap);
  margin-inline: auto;
}

.stage {
  position: absolute; inset: 0;
  background: #000; border-radius: var(--radius); overflow: hidden;
  box-shadow: var(--shadow);
}
.player-mount, .player-mount iframe { width: 100%; height: 100%; border: 0; display: block; }

/*
 * The two places inside the player that lead away from this page, taken over
 * so playback can be handed off rather than doubled.
 *
 * The first is the whole top row, not just the title sitting in it: the row is
 * one big link across its full width, and stopping partway along only moves
 * where the bug starts. It runs to the buttons at the right-hand end of that
 * row — share, watch later — which are the player's and stay the player's.
 * The second is the YouTube wordmark down in the control bar.
 *
 * Both are placed off one number: the height of YouTube's chrome, which is a
 * row of square 48px buttons, or 60px once the player is big enough for its
 * large mode. Fullscreen is always large mode; on the page this layout only
 * reaches that width on a very wide screen. Three slots is the widest that
 * right-hand group runs to.
 */
.stage { --yt-chrome: 48px; }
@media (min-width: 1800px) { .stage { --yt-chrome: 60px; } }

.stage__link {
  position: absolute; z-index: 1;
  padding: 0; border: 0; background: none; cursor: pointer;
}
/* Nothing to hand over until there is a video to hand over. */
.stage:not([data-has-video]) .stage__link { display: none; }

.stage__link--title {
  top: 0; left: 0; right: calc(var(--yt-chrome) * 3); height: var(--yt-chrome);
}

.stage__link--logo {
  bottom: 0; right: var(--yt-chrome);
  width: calc(var(--yt-chrome) * 1.6); height: var(--yt-chrome);
}

/* The floating card. Only these properties change — the iframe is never
   detached, which is why audio does not break when it shrinks. */
.stage.is-mini {
  position: fixed; inset: auto;
  width: min(340px, 74vw); aspect-ratio: 16 / 9;
  z-index: 60; border: 1px solid var(--border);
  cursor: grab;
}
.stage.is-mini.is-dragging { cursor: grabbing; user-select: none; }
.stage.is-animating { transition: left .24s cubic-bezier(.2, .9, .3, 1), top .24s cubic-bezier(.2, .9, .3, 1); }
.stage.is-docked { opacity: .93; }
.stage.is-docked .mini-bar__controls { display: none; }
.stage.is-popped { display: none; }

@media (prefers-reduced-motion: reduce) { .stage.is-animating { transition: none; } }

/* Mini-player chrome sits above the iframe and is the drag surface. */
.mini-bar {
  position: absolute; inset: 0 0 auto 0; z-index: 2;
  display: none; align-items: center; gap: 8px; padding: 7px 9px;
  grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
  background: linear-gradient(180deg, rgba(0, 0, 0, .82), rgba(0, 0, 0, 0));
  color: #fff; touch-action: none;
}
.stage.is-mini .mini-bar { display: flex; }
.mini-bar__title {
  flex: 1; min-width: 0; font-size: 12px; font-weight: 600;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Named a column of its own so the fullscreen grid can hold it dead centre
   whatever the title beside it is doing. Inert while the bar is a flexbox. */
.mini-bar__group { display: flex; align-items: center; gap: 8px; grid-column: 2; }
.mini-bar__controls { display: flex; gap: 2px; }
/* Nine buttons leave a 340px card no room for the title it also carries, and
   fullscreen is a press away on the page's own bar. The same button is what
   brings the screen back further down, where the card is not in the way. */
.stage.is-mini .mini-bar [data-action="fullscreen"] { display: none; }

/* Fullscreen. Only the stage goes up, so the screen holds the video and
   nothing else of ours — but then the page's control bar is left behind on the
   page, out of sight, which would make fullscreen a one-way door. So the mini
   player's chrome comes along instead: the same buttons, over the top of the
   video, where they are the only way back short of Esc. The mini-player's own
   rules would otherwise keep the stage at card size while it is the fullscreen
   element, so the geometry is restated here.

   Written out twice rather than as one selector list: a browser that does not
   know the prefixed pseudo-class throws away the whole rule it appears in. */
.stage:fullscreen {
  position: fixed; inset: 0;
  width: 100%; height: 100%; max-width: none;
  aspect-ratio: auto; border: 0; border-radius: 0; box-shadow: none;
  --yt-chrome: 60px;
}
.stage:fullscreen .mini-bar { display: grid; padding: 12px 16px; pointer-events: none; }
.stage:fullscreen .mini-bar > * { pointer-events: auto; }
.stage:fullscreen .mini-bar__title { display: none; }

.stage:-webkit-full-screen {
  position: fixed; inset: 0;
  width: 100%; height: 100%; max-width: none;
  aspect-ratio: auto; border: 0; border-radius: 0; box-shadow: none;
  --yt-chrome: 60px;
}
.stage:-webkit-full-screen .mini-bar { display: grid; padding: 12px 16px; pointer-events: none; }
.stage:-webkit-full-screen .mini-bar > * { pointer-events: auto; }
.stage:-webkit-full-screen .mini-bar__title { display: none; }

/* The bar is the card's, sized for a 340px card: 32px buttons set 2px apart.
   On a whole screen that is the smallest the buttons ever get, with the most
   room they ever have, and a finger landed on the wrong one. So here they go
   up to the size the page's own bar draws them, and further apart still —
   there is nothing else on the screen for them to make room for. */
.stage:fullscreen .mini-bar .ctl { width: 44px; height: 44px; }
.stage:fullscreen .mini-bar .ctl--primary { width: 52px; height: 52px; }
.stage:fullscreen .mini-bar .controls--compact { column-gap: 10px; }
.stage:fullscreen .mini-bar .controls__transport { gap: 10px; }
.stage:fullscreen .mini-bar__group { gap: 14px; }

.stage:-webkit-full-screen .mini-bar .ctl { width: 44px; height: 44px; }
.stage:-webkit-full-screen .mini-bar .ctl--primary { width: 52px; height: 52px; }
.stage:-webkit-full-screen .mini-bar .controls--compact { column-gap: 10px; }
.stage:-webkit-full-screen .mini-bar .controls__transport { gap: 10px; }
.stage:-webkit-full-screen .mini-bar__group { gap: 14px; }

/* Left showing, that bar covers the top of the picture for as long as
   fullscreen lasts — and unlike the page, fullscreen has nowhere else to put
   it. So it goes five seconds after the last thing you did, and a press on
   the picture brings it back. The switch in the middle is the way out for
   anyone who would rather it stayed; while it is on, nothing here ever
   applies, because the class is only ever set when the switch is off.

   Hidden, the bar stops taking presses altogether, which keeps an invisible
   button from being worked by the press that only asked for it back. The
   catcher below hears that press instead: the player is a cross-origin iframe
   and a pointer over it never reaches this page, so something of ours has to
   be over the picture to hear it. It used to be the bar's own strip, and no
   more — a press at the foot of the picture, where a hand goes first, went to
   the player and left the bar where it was.

   The catcher covers everything but the player's own controls, which stay
   the player's: the buttons at the right-hand end of its top row (volume,
   subtitles, settings), and the whole of its chrome at the foot, which is
   three rows deep — the wordmark's row at the very bottom, the progress bar
   above it, and above that the row with the time at the left and the
   fullscreen button at the right; two and a half buttons' height covers the
   lot. Two rectangles rather than one with holes cut in it: the top row
   short of its buttons, then everything from there down to that chrome.
   Nothing of the player's is showing up here at that moment either: it hides
   its own chrome on the same idle the bar went on, and the first press
   brings ours back and stands the catcher down, so the next one is the
   player's.

   A touch on the catcher must not pan the page behind the screen: the page
   is still there under the fullscreen element, and a scroll of it is what
   folds the player into its floating card — which, in fullscreen, showed as
   the picture jumping and coming back.

   The pointer goes with the bar. Below it the iframe owns the cursor, and
   hides it along with its own controls. */
.mini-bar { transition: opacity .3s ease; }
@media (prefers-reduced-motion: reduce) { .mini-bar { transition: none; } }

.stage.is-chrome-idle .mini-bar { opacity: 0; pointer-events: none; }
.stage.is-chrome-idle .mini-bar > * { pointer-events: none; }
.stage.is-chrome-idle { cursor: none; }

.stage__wake {
  position: absolute; inset: 0; z-index: 3;
  display: none; pointer-events: none; touch-action: none;
}
.stage__wake::before, .stage__wake::after {
  content: ""; position: absolute; left: 0; pointer-events: auto;
}
.stage__wake::before { top: 0; right: calc(var(--yt-chrome) * 3); height: var(--yt-chrome); }
.stage__wake::after { top: var(--yt-chrome); right: 0; bottom: calc(var(--yt-chrome) * 2.5); }
/* The class is only ever set in fullscreen (see wakeChrome), so this needs
   no :fullscreen of its own. */
.stage.is-chrome-idle .stage__wake { display: block; }

/* The player's own fullscreen button, taken over while the stage has the
   screen. From inside our fullscreen a press on it did nothing — the screen
   is already taken, by an element the player's own document knows nothing
   about — and the one thing it can still mean is out, so that is what it
   does. The button sits at the right of the row above the progress bar — the
   third row up of the player's chrome, over the wordmark's row and the bar
   itself — so the catcher starts just clear of the bar and runs a little
   past the button's top, room enough for the taller buttons the player
   draws once it is big enough for its large mode. Left to the player
   everywhere else: on the page, and in the card, it is not drawn at all. It
   sits under the wake catcher, in the strip that one leaves to the player,
   so it is reachable whether our bar is showing or not: the player's chrome
   comes back on a press this page never hears, and the button then has to
   work. */
.stage__fullscreen {
  position: absolute; z-index: 1; display: none;
  bottom: calc(var(--yt-chrome) * 1.3); right: 0;
  width: calc(var(--yt-chrome) * 1.6); height: calc(var(--yt-chrome) * 1.2);
  padding: 0; border: 0; background: none; cursor: pointer; touch-action: none;
}
.stage:fullscreen .stage__fullscreen { display: block; }
.stage:-webkit-full-screen .stage__fullscreen { display: block; }

/* A start a link asked for, waiting for its press (see "Starting without a
   press" in app.js): the video running silently, or not at all. The veil
   stands over the whole picture so the press lands here rather than on the
   player, whose answer to a tap is to pause; the card's own bar stays over
   it. Inert and invisible the rest of the time. The same dim and pill as the
   floating window's, which asks for the same thing. */
.stage__start {
  position: absolute; inset: 0; z-index: 2;
  display: grid; place-items: center;
  margin: 0; padding: 0; border: 0; border-radius: 0;
  background: rgba(0, 0, 0, .28); color: #fff; font: inherit; cursor: pointer;
  opacity: 0; visibility: hidden; pointer-events: none;
  transition: opacity .18s ease;
}
.stage.needs-sound .stage__start,
.stage.needs-play .stage__start {
  opacity: 1; visibility: visible; pointer-events: auto;
}
.stage__start-pill {
  display: flex; flex-direction: column; align-items: center; gap: 4px;
  padding: 9px 16px; border-radius: 999px;
  background: rgba(0, 0, 0, .78);
  font-size: 14px; font-weight: 600; white-space: nowrap;
}
/* The installed app's line under "for sound": where the setting is that
   would spare the tap next time. Quieter, and allowed to wrap. */
.stage__start-tip {
  display: none; max-width: min(420px, 80vw);
  font-size: 12px; font-weight: 400; line-height: 1.4; white-space: normal; text-align: center;
  opacity: .85;
}
.offer-sound-setting .stage.needs-sound .stage__start-tip { display: block; }
.offer-sound-setting .stage.needs-sound .stage__start-pill { border-radius: 16px; }
.stage__start-sound, .stage__start-play { display: inline-flex; align-items: center; gap: 8px; }
.stage__start-play,
.stage.needs-play .stage__start-sound { display: none; }
.stage.needs-play .stage__start-play { display: inline-flex; }
.stage__start:focus-visible .stage__start-pill { outline: 2px solid #fff; outline-offset: 2px; }
/* The card is a third the size; so is its pill, and the tip stays out. */
.stage.is-mini .stage__start-pill { padding: 6px 12px; font-size: 12px; }
.offer-sound-setting .stage.is-mini .stage__start-tip { display: none; }
.offer-sound-setting .stage.is-mini .stage__start-pill { border-radius: 999px; }

/* The video's title is the player's own, drawn in the player's own top row.
   Ours was a second copy of it laid over the first, and over the link the
   first one is — so in fullscreen the bar carries buttons and nothing else.
   The card keeps its copy: at 340px the player draws no chrome worth reading,
   and that title is the only thing naming what is in the card. */

/* The switch rides with the buttons, ahead of the transport controls, and the
   whole cluster sits in the middle of the bar rather than in a corner: the
   browser puts its own fullscreen chrome in both of those — Safari's close and
   picture-in-picture buttons at the top left, the desktop browsers' at the top
   right — and ours ended up underneath.

   Left out of the card for the same reason as fullscreen above — no room, and
   nothing there hides itself, so it would have nothing to say. */
.stage.is-mini .mini-bar [data-action="pinbar"] { display: none; }

/* A 340px card has no room for the player's own chrome either, and its bar is
   what you press there — a hand-off catcher would only be in the way. */
.stage.is-mini .stage__link { display: none; }

/* ---------------------------------------------------------------- controls */

/*
 * Three columns: the transport in the middle, so that the play button sits
 * under the centre of the video, and the volume at the end of the row. The
 * outer columns are equal, which is what keeps the middle one centred when
 * only one of them has anything in it.
 */
.controls {
  display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, auto) minmax(0, 1fr);
  align-items: center; column-gap: 6px; row-gap: 4px; padding: 12px 0 4px;
}
.controls--compact { column-gap: 2px; padding: 4px 0; }
/* Allowed to shrink, as the bar always was: a phone narrower than its buttons
   squeezes them rather than growing wider than the page. */
.controls__transport { display: flex; align-items: center; gap: 6px; grid-column: 2; min-width: 0; }
.controls--compact .controls__transport { gap: 2px; }

.volume {
  display: flex; align-items: center; gap: 2px; grid-column: 3; justify-self: end;
}
/* Beside the transport when the row has room for it, under it when not. The
   bar measures that itself (watchBarRoom in app.js) and says so with a class.
   A container query would do the same; it was passed over for leaving a
   tooltip elsewhere on the page stuck at the start of its fade, which turned
   out to be the test's browser drawing a background tab once a second (see
   findChromium in tests/ui.test.mjs), and no fault of the query. */
.controls.is-stacked .volume { grid-column: 1 / -1; justify-self: center; }

/* A native slider, drawn in the brand colour; the platform's own thumb. */
/* 16px type for the same reason as the fields further down: anything smaller
   on a form control makes iPhone Safari zoom the page in on focus. */
.volume__slider {
  width: 100px; height: 42px; margin: 0; padding: 0; font-size: 16px;
  background: none; accent-color: var(--accent); cursor: pointer;
}
.volume__slider:focus-visible { outline: 2px solid var(--accent); outline-offset: -4px; border-radius: 8px; }

.ctl {
  display: inline-flex; align-items: center; justify-content: center;
  width: 42px; height: 42px; padding: 0;
  background: none; border: none; border-radius: 50%;
  color: inherit; cursor: pointer;
}
.ctl:hover { background: var(--surface-2); }
.ctl:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
/* Repeat-one is the non-default loop mode, so it gets a tint on top of the
   numeral in the glyph. */
.ctl.is-on { color: var(--accent); }
.mini-bar .ctl.is-on, .pip-body .ctl.is-on { color: #ff5a5a; }

.ctl--primary { width: 52px; height: 52px; background: var(--accent); color: #fff; }
.ctl--primary:hover { background: var(--accent); filter: brightness(1.1); }

.mini-bar .ctl, .controls--compact .ctl { width: 32px; height: 32px; color: inherit; }
.mini-bar .ctl:hover { background: rgba(255, 255, 255, .18); }
.mini-bar .ctl--primary, .controls--compact .ctl--primary { width: 36px; height: 36px; }

/* --------------------------------------------------------------- now / queue */

.now { margin: 6px 0 0; font-size: 17px; font-weight: 700; line-height: 1.35; }
/* After the title on the same line, in the small quiet type of a byline; the
   title wraps and the channel follows the end of it. */
.now__channel { margin-left: 8px; color: var(--text-dim); font-size: 13px; font-weight: 400; }
.now__channel:empty { display: none; }
/* A supporter's collect button, last on the line after the channel: an
   extra, so it sits in the byline's quiet colour and no taller than the line
   it ends (the stage's height is measured off this line). Shown only when
   the root carries the perk's class; a visitor's line is unchanged. */
.now .now__collect {
  display: none; width: 22px; height: 22px; margin-left: 6px;
  vertical-align: middle; border-radius: 6px;
}
.now__collect .icon { width: 16px; height: 16px; }
html.perk-collect .now .now__collect { display: inline-flex; }
/* Kept somewhere: the bookmark fills, in the page's own colour. */
.now__collect[aria-pressed="true"] { color: var(--text); }

.panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 14px 8px; }
.panel__head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 8px; }
.panel__head h2 { margin: 0; font-size: 15px; }
.panel__count { color: var(--text-dim); font-size: 12.5px; }

/*
 * Auto-shuffle, riding the queue's heading: name, switch, button — read left
 * to right as what it is, whether it is on, and the one thing you can ask of
 * it now. Pushed to the right edge so the heading keeps the left.
 *
 * The switch and the button are sized to one height between them: two controls
 * of different heights sitting side by side read as two unrelated things.
 */
/* On a line too short for all of it (measured by watchQueueHead in app.js)
   the two names go, and the line stays one line. */
.panel__head.is-tight h2,
.panel__head.is-tight .shufflerow__label { display: none; }

/* The shuffle row and the editing switch, one group at the right end. On the
   narrowest phones the two do not fit on one line, and a group that will not
   wrap widens the whole page (see .panel__tools); there the switch goes
   under the row, at the same right end. */
/* The queue's head is centred on its line rather than set on a baseline:
   half of it is boxes — the magnifier, the switch, the buttons — and a box
   has no baseline. Set on one, the count was the only thing on it once the
   heading was put away (see the player-only view), and stood at the top of
   the line with the boxes centred under it. */
.panel--queue .panel__head { align-items: center; }
.queuetools { display: flex; align-items: center; gap: 8px; margin-left: auto; }
@media (max-width: 900px) {
  .queuetools { flex-wrap: wrap; justify-content: flex-end; }
}
.shufflerow {
  position: relative;
  display: flex; align-items: center; gap: 8px;
}
.shufflerow__label { font-size: 12.5px; font-weight: 600; white-space: nowrap; }
.shufflerow .switch { width: 44px; height: 26px; }
.shufflerow .switch::after { width: 22px; height: 22px; }
.shufflerow .switch[aria-checked="true"]::after { transform: translateX(18px); }
.shufflerow .linkish { height: 26px; padding: 0 10px; font-size: 12px; }
/* Where the names go (a line too short for them, the player-only view), a
   glyph stands in for each, so that two switches side by side can still be
   told apart: a play mark for autoplay, the shuffle mark for the shuffle. */
.shufflerow__glyph { display: none; align-items: center; color: var(--text-dim); }
.panel__head.is-tight .shufflerow__glyph { display: inline-flex; }
/* Where the line is too short even without the names (a phone), the
   reshuffle button's word goes too: its glyph, sized to the editing switch. */
.panel__head.is-tighter #reshuffle { width: 34px; padding: 0; justify-content: center; }
.panel__head.is-tighter #reshuffle > span { display: none; }

/* The autoplay switch is a supporter's (see perks-core.js); a visitor's head
   is the shuffle and the editing switch, as it always was. */
.autoplayrow { display: none; }
html.perk-autoplay .autoplayrow { display: flex; }

/*
 * One explanation for the whole group rather than a line of prose under it:
 * the row is three controls that only make sense together, and the queue's
 * head is not the place to spend three lines saying so. Hovering or tabbing
 * into any part of it — the name included — brings the same note up.
 */
.shufflerow__help {
  position: absolute; top: calc(100% + 6px); right: 0; z-index: 6;
  width: max-content; max-width: min(260px, 72vw);
  margin: 0; padding: 7px 10px;
  color: var(--text); background: var(--surface-2);
  border: 1px solid var(--border); border-radius: 9px;
  box-shadow: 0 8px 20px rgba(0, 0, 0, .35);
  font-size: 11.5px; font-weight: 400; line-height: 1.45; text-align: left;
  opacity: 0; visibility: hidden; pointer-events: none;
  transition: opacity .12s ease;
}
.shufflerow:hover .shufflerow__help,
.shufflerow:focus-within .shufflerow__help { opacity: 1; visibility: visible; }

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

.search { position: relative; margin: 2px 0 10px; }
.search input {
  width: 100%; padding: 8px 36px 8px 32px; font-size: 13px; color: var(--text);
  background: var(--surface-2); border: 1px solid var(--border); border-radius: 9px;
}
.search input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.search > .icon {
  position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
  width: 15px; height: 15px; color: var(--text-dim); pointer-events: none;
}
/* The way to empty the field. A search field draws one of its own on the
   desktop, but not on a phone or a tablet — iOS Safari and Android Chrome
   draw nothing, or only while the field has focus — and there it was the
   one place that needed it. So the field's own is put away everywhere and
   this one stands in for it, shown only while there is something to clear. */
.search input::-webkit-search-cancel-button,
.search input::-webkit-search-decoration { -webkit-appearance: none; appearance: none; }
.search__clear {
  position: absolute; right: 4px; top: 50%; transform: translateY(-50%);
  display: inline-flex; align-items: center; justify-content: center;
  width: 30px; height: 30px; padding: 0; border: 0; border-radius: 50%;
  background: none; color: var(--text-dim); cursor: pointer;
}
.search__clear:hover { background: var(--border); color: var(--text); }
.search__clear:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.search__clear .icon { width: 16px; height: 16px; }

/*
 * A scrollbar is drawn down the right edge of the rows — which is exactly
 * where the drag handle and the remove button sit, so the two share the same
 * pixels and a click meant for "remove" lands on the bar instead. An overlay
 * bar, as on macOS, sits straight on top of the button.
 *
 * So the bar gets a lane of its own: the list widens by the panel's own
 * padding and pads a gap back, which puts the bar beside the rows rather
 * than over them. The gap is wider than it needs to be for the pixels alone
 * — a pointer resting on the bar should not look like it is on the button
 * next to it, and the bar grows under the pointer on some platforms.
 * `stable` holds the lane open even when the list is too short to scroll, so
 * rows do not shift sideways as it fills up.
 */
.queue {
  list-style: none; margin: 0 -14px 0 0; padding: 0 12px 0 0;
  max-height: 60vh; overflow-y: auto; overscroll-behavior: contain;
  scrollbar-gutter: stable;
}

/* A narrow bar in both lists, so the lane above is wide enough to hold it.
   The standard properties are what Chrome and Firefox read; the -webkit-
   rules draw the same bar in Safari, which has no scrollbar-width. Either
   also settles the bar into the layout instead of floating it over the rows. */
.queue, .library { scrollbar-width: thin; scrollbar-color: var(--scroll-thumb) transparent; }
.queue::-webkit-scrollbar, .library::-webkit-scrollbar { width: 8px; }
.queue::-webkit-scrollbar-track, .library::-webkit-scrollbar-track { background: transparent; }
.queue::-webkit-scrollbar-thumb, .library::-webkit-scrollbar-thumb {
  background: var(--scroll-thumb); border-radius: 999px;
}

/* Beside the player the column decides the height, not a fraction of the
   window: the list fills what the panel has and stops where the library does. */
@media (min-width: 901px) and (min-height: 601px) { .queue { max-height: none; } }
.queue__empty { color: var(--text-dim); font-size: 13px; padding: 10px 2px; }

/* The row is the button plus its edit controls; the button takes the slack. */
.queue__item { display: flex; align-items: center; gap: 2px; border-radius: 9px; }
/* Little room above and below: the picture is what sets the row's height,
   and it runs almost to the row's edges so that height is all picture. */
.queue__btn {
  display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0;
  padding: 2px 6px; background: none; border: none; border-radius: 9px;
  color: inherit; cursor: pointer; text-align: left; font: inherit;
}
.queue__btn:hover { background: var(--surface-2); }
.queue__item.is-current .queue__btn { background: var(--surface-2); }
/* A row still being read in: looks the part, but nothing to press yet. */
.queue__item.is-preview .queue__btn { cursor: default; }
.queue__item.is-preview .queue__btn:hover { background: none; }
.queue__num { flex: none; width: 26px; color: var(--text-dim); font-size: 12px; text-align: right; font-variant-numeric: tabular-nums; }
/* Big enough to recognise a frame at a glance, not only a colour — and as
   tall as the row, in the video's own shape, so it is all frame. */
.queue__thumb {
  flex: none; width: 140px; aspect-ratio: 16 / 9; object-fit: cover;
  border-radius: 6px; background: var(--surface-2);
}
.queue__text { min-width: 0; display: flex; flex-direction: column; }
/* The same weight as a list's name in the library: the title is what a row
   is read by, and the channel under it is the quieter of the two. */
.queue__title { font-size: 13.5px; font-weight: 600; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.queue__channel { color: var(--text-dim); font-size: 11.5px; }
.queue__item.is-current .queue__channel { color: var(--accent); font-weight: 600; }

/* ------------------------------------------------------------ queue editing */

.queue__actions { display: flex; gap: 1px; flex: none; }
/* Only while editing (the switch at the queue's head): the rest of the time
   the width they took is the title's. */
.queue:not(.is-editing) .queue__actions { display: none; }
/* Sized to the shuffle row beside it, and shown pressed while it is on. */
#queue-edit { height: 26px; width: 34px; padding: 0; justify-content: center; }
#queue-edit[aria-pressed="true"] { background: var(--surface-2); border-color: var(--text-dim); }

/* ------------------------------------------------------------- queue search */

/* The magnifier rides the count, so it is drawn to the count's scale rather
   than the switch's. Pressed while the box is out. */
#queue-find {
  height: 24px; width: 30px; padding: 0; justify-content: center;
  margin-left: -4px; color: var(--text-dim);
}
#queue-find:hover { color: var(--text); }
#queue-find[aria-pressed="true"] { background: var(--surface-2); border-color: var(--text-dim); color: var(--text); }
/* Without this a touch-drag on the handle scrolls the list instead. */
.iconbtn--grip { cursor: grab; touch-action: none; }
.queue.is-reordering .iconbtn--grip { cursor: grabbing; }

/* The row being carried, and the slot it would drop into. */
.queue__item.is-dragging { opacity: .4; }
.queue__item.is-drop { background: var(--surface-2); box-shadow: inset 0 2px 0 var(--accent); }
.queue.is-reordering .queue__btn { pointer-events: none; }

.excluded {
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  margin: 0 2px 8px; color: var(--text-dim); font-size: 12px;
}

/* Watched by an observer to pull in the next slice as the list is scrolled. */
.queue__sentinel { height: 1px; list-style: none; }
.queue__loading { padding: 10px 2px; color: var(--text-dim); font-size: 12px; text-align: center; }

/* ----------------------------------------------------------------- library */

.panel__head { flex-wrap: wrap; }
.panel__head .linkish--sm { padding: 3px 9px; font-size: 12px; }
/* One group, so the buttons wrap together rather than stranding one on a line. */
.panel__tools { display: flex; gap: 6px; margin-left: auto; }
/* On a phone the four do not fit on one line, and a group that will not
   wrap is wider than the panel: it held the whole page 35px wider than the
   screen. There they wrap within the group, kept to its right end. */
@media (max-width: 900px) {
  .panel__tools { flex-wrap: wrap; justify-content: flex-end; }
}
.panel__note { margin: 8px 2px 4px; color: var(--text-dim); font-size: 11.5px; }

/* Its rows carry buttons on the same edge as the queue's, so it keeps the
   same lane for the bar. */
.library {
  list-style: none; margin: 0 -14px 0 0; padding: 0 12px 0 0;
  max-height: 34vh; overflow-y: auto; scrollbar-gutter: stable;
}
.library__empty { color: var(--text-dim); font-size: 13px; padding: 8px 2px; line-height: 1.5; }

.library__item {
  display: flex; align-items: center; gap: 10px;
  padding: 8px 6px; border-radius: 9px;
}
.library__item + .library__item { border-top: 1px solid var(--border); }
.library__item:hover { background: var(--surface-2); }
.library__item.is-off .library__name,
.library__item.is-off .library__meta { opacity: .45; }
/* A list that could not be read says so where it sits, in the colour the
   editor uses for a line it could not read. */
.library__item.is-failed .library__meta { color: var(--error); }

/* Small version of the iOS switch, reusing the same visual language. */
.switch--sm { width: 40px; height: 24px; flex: none; }
.switch--sm::after { width: 20px; height: 20px; }
.switch--sm[aria-checked="true"]::after { transform: translateX(16px); }

.library__text { flex: 1; min-width: 0; }
.library__name {
  font-size: 13.5px; font-weight: 600; line-height: 1.35;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.library__meta {
  color: var(--text-dim); font-size: 11.5px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

.library__actions { display: flex; gap: 2px; flex: none; }
.iconbtn {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; padding: 0;
  background: none; border: none; border-radius: 7px;
  color: var(--text-dim); cursor: pointer; font-size: 13px;
}
.iconbtn:hover { background: var(--border); color: var(--text); }
.iconbtn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.iconbtn[disabled] { cursor: default; opacity: .5; }
.iconbtn[disabled]:hover { background: none; color: var(--text-dim); }

/* A playlist being re-read from YouTube: its own arrow turns while it waits. */
.library__item.is-syncing .iconbtn[data-sync-list] { color: var(--accent); opacity: 1; }
.library__item.is-syncing .iconbtn[data-sync-list] .icon {
  animation: spin 1.1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  .library__item.is-syncing .iconbtn[data-sync-list] .icon { animation: none; }
}

/* The auto-sync picker, sized to sit in the same row as the switches above. */
.select {
  flex: none; max-width: 50%;
  padding: 6px 9px;
  background: var(--surface-2); color: var(--text);
  border: 1px solid var(--border); border-radius: 8px;
  font: inherit; font-size: 12.5px; cursor: pointer;
}
.select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

.queue__from { color: var(--text-dim); font-size: 11px; }

/* --------------------------------------------------- custom playlist marks */

/* The face a hand-made list wears: its colour, with a glyph, an emoji, or an
   uploaded cover sitting on it. */
.library__chip {
  display: inline-flex; align-items: center; justify-content: center; flex: none;
  width: 30px; height: 30px; border-radius: 8px; overflow: hidden;
  background: var(--chip, var(--surface-2)); color: #fff; font-size: 15px; line-height: 1;
}
.library__chip img { width: 100%; height: 100%; object-fit: cover; }
.library__chip .icon { width: 17px; height: 17px; }

/* ------------------------------------------- the custom playlist editor */

/* Fixed, said outright: a modal dialog sits in the top layer, and an engine
   that takes `relative` literally lays it out at the top of the document —
   which, on a phone scrolled down to the library, is somewhere above the
   screen, leaving only a grey page and no sign of the sheet. Also what the
   reading overlay is placed against. */
.sheet {
  position: fixed;
  width: min(900px, calc(100vw - 24px)); max-height: min(90vh, 860px);
  padding: 0; border: 1px solid var(--border); border-radius: 16px;
  background: var(--surface); color: var(--text);
}
.sheet::backdrop { background: rgb(0 0 0 / .45); }
/* No bottom padding: the pinned actions below carry it, and a bottom margin
   there would be dropped from the scroll extent and clip the last line. */
.sheet__form { display: block; padding: 20px 30px 0; overflow-y: auto; max-height: inherit; }

/* One column, read top to bottom: what is in the list, what it is called,
   then what it looks like. Only the contents want the sheet's whole width —
   their lines are URLs, and a truncated one names no playlist in particular.
   Only the label that opens a sheet goes without the gap above it; the name,
   trailing the contents, wants the same gap as any other. */
.sheet__title + .lbl { margin-top: 0; }
/* One short line, and no longer the sheet's first question: a smaller field
   than the import box downstairs, sized nearer the rows above it. */
#custom-name { max-width: 340px; padding: 7px 11px; font-size: 13.5px; }
/* On a phone the space between things is what decides whether the last line
   still lands above the fold. */
@media (max-width: 680px) {
  .sheet .field { margin-top: 12px; }
  .sheet .hint { margin-top: 6px; }
}
.sheet__title { margin: 0 0 12px; font-size: 17px; }
/* The chrome button hides its label on a phone; in here there is no glyph
   behind it to carry the meaning, so the words have to stay. */
.sheet .btn span { display: inline; }

.sheet input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

/*
 * The spec as rows rather than as text.
 *
 * In a textarea a long URL wraps, and a wrapped entry looks exactly like two
 * entries — there is no way to tell "one per line" by looking. A row is one
 * entry by construction: it carries its own number, says what it was read as,
 * and scrolls sideways rather than folding when the URL is longer than the
 * field.
 */
.lines {
  --line-h: 34px;          /* one row, and the unit the scroller counts in */
  --line-rows: 5;          /* how many are shown before it starts scrolling */
  --line-gutter: 26px;     /* the number column inside each field */
  list-style: none; counter-reset: line;
  /*
   * The side padding is room for focus rings, and the negative margin gives
   * it straight back so the fields still start on the sheet's own left edge.
   *
   * `overflow-y: auto` makes a box a scroll container on both axes, clipping
   * at its padding edge, and a ring is drawn outside the control it belongs
   * to. Without the room, the ring on a row that begins at that very edge
   * lost its left side.
   */
  margin: -1px -4px; padding: 1px 4px;
  /* A fixed number of rows before it scrolls: enough to take a list in at a
     glance without the sheet growing by one every time a line is added. The
     padding is added back so they are whole rows. */
  max-height: calc(var(--line-h) * var(--line-rows) + 2px); overflow-y: auto;
}
/* The contents are the one part of the sheet that grows without limit, and on
   a phone five rows is a screenful on its own -- enough to push the note below
   the list, the one saying how a line is read, down behind the buttons. Two
   rows here, and the third starts scrolling. */
@media (max-width: 680px) {
  .lines { --line-rows: 2; }
}
.line {
  display: flex; align-items: center; gap: 6px;
  height: var(--line-h); counter-increment: line;
}
/*
 * The number sits inside the field, behind a rule, the way a fixed prefix
 * does. Outside it the row began a gutter's width to the left of every other
 * field on the sheet, and the two edges never lined up.
 */
.line__field {
  display: flex; align-items: stretch; flex: 1; min-width: 0;
  /* Grey all the way across, the row read as something already filled in and
     closed, so it wears a wash of the accent instead. */
  background: var(--accent-wash);
  border: 1px solid var(--border); border-radius: 8px; overflow: hidden;
}
/* The number is a fixed prefix rather than part of what you type, so it keeps
   the quiet ground: the pale strip ends where the field proper begins. */
.line__field::before {
  content: counter(line);
  display: flex; align-items: center; justify-content: flex-end;
  flex: none; width: var(--line-gutter); padding-right: 7px;
  background: var(--accent-wash-2);
  border-right: 1px solid var(--border);
  color: var(--text-dim); font-size: 11.5px; font-variant-numeric: tabular-nums;
}
/* The field carries the ring for the input inside it, in the one style the
   whole app uses. */
.line__field:focus-within { outline: 2px solid var(--accent); outline-offset: 1px; }
/* Two classes deep on purpose: the global input[type="text"] rule is an
   element-plus-attribute selector, which outranks a lone class and was
   putting its own 11px padding and 15px type into a 34px row. */
.lines .line__input {
  flex: 1; min-width: 0;
  padding: 4px 10px; font-size: 12.5px; line-height: 1.4;
  color: var(--text); background: none;
  border: none; border-radius: 0;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  text-overflow: ellipsis;
}
.lines .line__input:focus-visible { outline: none; }
/* What the line was understood to be, said where the line is. */
.line__kind {
  flex: none; min-width: 3.4em; text-align: center;
  font-size: 11px; color: var(--text-dim);
  padding: 3px 6px; border-radius: 999px; background: var(--surface-2);
}
.line[data-kind="empty"] .line__kind { visibility: hidden; }
.line[data-kind="bad"] .line__kind { color: var(--error); }
.line[data-kind="bad"] .line__field { border-color: var(--error); }

/* The fields now start at the sheet's own edge, so this does too. */
#custom-add-line { margin: 8px 0 0; padding: 4px 10px; font-size: 12px; }
.sheet .lbl { margin-top: 12px; }
.sheet .hint { margin-top: 6px; }
.input--tiny { width: 5em !important; text-align: center; }

/* A fieldset's default min-inline-size is min-content, so it refuses to be
   narrower than its widest row and pushes past the sheet instead of letting
   that row scroll. */
.field { margin: 13px 0 0; padding: 0; border: none; min-width: 0; }
.field legend { padding: 0; font-size: 12px; color: var(--text-dim); }
.sublbl { display: inline-block; margin: 10px 8px 0 0; font-size: 12px; color: var(--text-dim); white-space: nowrap; }

/* One row, always. The choices are a set to be seen at a glance, and a
   wrapped tail reads as a separate group rather than the end of this one, so
   they give up width to each other rather than the line. */
/* The glyphs and the emoji field are one choice, so they read as one row.
   It may wrap on a narrow sheet, where there is no room to be one. */
.iconrow { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 12px; margin-top: 8px; }
.iconrow .pickscroll { flex: 0 1 auto; min-width: 0; margin: -4px; }
.iconrow .sublbl { margin: 0; }
/*
 * Eleven picks do not fit across a phone, and the row was asked to stay one
 * line. So it stays one line and that line scrolls, instead of the last two
 * being cut off at the sheet's edge. The picks keep their full size rather
 * than squeezing down around drawings that do not squeeze with them. Padding
 * pulled back by a margin gives the focus ring somewhere to be drawn, the
 * way the line list does it.
 */
/*
 * A row of picks, with an arrow at each end for the part that does not fit.
 *
 * Eleven icon picks need 400px and a phone gives them under 300, so the row
 * scrolls. Nothing about a row cut off at the edge says so, and the pick past
 * the edge is not merely clipped but entirely out of sight, so the arrows say
 * it instead: they appear only when there is something to reach, and the one
 * at an end already reached is disabled. On a wide screen every row fits and
 * neither arrow is ever shown.
 */
.pickscroll { display: flex; align-items: center; margin: 4px -4px -4px; }
.pickrow {
  display: flex; flex-wrap: nowrap; gap: 6px;
  flex: 1 1 auto; min-width: 0;
  padding: 4px;              /* room for a pick's focus ring, which is drawn
                                outside it and the scroll would clip */
  overflow-x: auto; scrollbar-width: none;
}
.pickrow::-webkit-scrollbar { display: none; }
.pickscroll__arrow {
  display: inline-flex; align-items: center; justify-content: center;
  flex: none; width: 22px; height: 30px; padding: 0;
  background: none; border: none; border-radius: 7px;
  color: var(--text-dim); cursor: pointer;
}
/* The button's own display would otherwise win over the hidden attribute. */
.pickscroll__arrow[hidden] { display: none; }
/* The row's padding is its ring room; the arrows sit outside that padding and
   need their own, so that a shown arrow still begins on the sheet's edge. */
.pickscroll__arrow[data-scroll="start"] { margin-left: 4px; transform: scaleX(-1); }
.pickscroll__arrow[data-scroll="end"] { margin-right: 4px; }
.pickscroll__arrow:disabled { opacity: .28; cursor: default; }
.pickscroll__arrow:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.pick {
  display: inline-flex; align-items: center; justify-content: center;
  flex: none; width: 30px; height: 30px;
  padding: 0; cursor: pointer;
  background: var(--surface-2); border: 2px solid transparent; border-radius: 9px;
  color: var(--text);
}
.pick .icon { flex: none; }
.pick:hover { border-color: var(--border); }
.pick[aria-pressed="true"] { border-color: var(--accent); }
.pick:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.pick--color { border-radius: 999px; }
/* Wearing nothing opens the row, in grey since it is the absence of a choice
   rather than one more of them; the two picture choices close it. Each is set
   off from the glyphs by a gap. */
.pick--none { margin-right: 10px; color: var(--text-dim); }
.pick--pictures { margin-left: 10px; }
.pick--color span { width: 18px; height: 18px; border-radius: 999px; background: var(--swatch); }

.coverrow { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 8px; }
.cover {
  display: inline-flex; align-items: center; justify-content: center; flex: none;
  width: 56px; height: 56px; border-radius: 10px; overflow: hidden;
  background: var(--chip, var(--surface-2)); color: #fff;
  border: 1px solid var(--border); font-size: 24px; line-height: 1;
}
.cover img { width: 100%; height: 100%; object-fit: cover; }
.cover .icon { width: 26px; height: 26px; }
/* No face at all: the slot shows the struck mark in grey, not a colour. */
.cover.is-bare { color: var(--text-dim); }
.sheet input[type="file"] { flex: 1; min-width: 12ch; font-size: 12px; color: var(--text-dim); }

/* What a field must have, and what it is complaining about. The message sits
   with the field rather than at the foot of the form, where it would be off
   the bottom of a sheet this tall. */
.req { color: var(--error); margin-right: 3px; font-weight: 700; }
.fielderror { margin: 6px 0 0; font-size: 12.5px; line-height: 1.45; color: var(--error); }
.sheet [aria-invalid="true"] { border-color: var(--error); }
.sheet [aria-invalid="true"]:focus-visible { outline-color: var(--error); }

/* Reading a list takes seconds and can run to many lines, so it says so in the
   middle of the sheet instead of on a line the form has scrolled past. */
.sheet__busy {
  position: absolute; inset: 0; z-index: 3;
  display: flex; align-items: center; justify-content: center;
  border-radius: inherit;
  background: var(--surface);
  background: color-mix(in srgb, var(--surface) 90%, transparent);
}
/* A card of its own: the form behind still shows through the overlay, and the
   message has to stay readable against whatever line it lands on. */
.sheet__busybox {
  display: flex; flex-direction: column; align-items: center; gap: 12px;
  max-width: calc(100% - 48px); padding: 22px 26px; text-align: center;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: 12px; box-shadow: var(--shadow);
}
.sheet__busytext { margin: 0; font-size: 14px; }
.spinner {
  width: 26px; height: 26px; border-radius: 50%;
  border: 3px solid var(--border); border-top-color: var(--accent);
  animation: spin .8s linear infinite;
}
.progress { width: 190px; height: 4px; border-radius: 999px; background: var(--border); overflow: hidden; }
.progress span { display: block; height: 100%; width: 0; background: var(--accent); transition: width .2s ease; }
@media (prefers-reduced-motion: reduce) {
  .spinner { animation: none; }
  .progress span { transition: none; }
}

/* Pinned to the bottom of the sheet, so Save is reachable on a short screen
   without scrolling past everything else first. */
.sheet__actions {
  position: sticky; bottom: 0; z-index: 1;
  display: flex; justify-content: flex-end; gap: 10px;
  margin: 14px -30px 0; padding: 14px 30px;
  background: var(--surface); border-top: 1px solid var(--border);
}

/* ------------------------------------------------- the other-devices sheet */

/* Narrower than the editor: a paragraph of explanation and a row of buttons
   want a column of prose, not the width a list of URLs needs. */
.sheet--sm { width: min(560px, calc(100vw - 24px)); }
.sheet--sm .sheet__form { padding: 20px 24px 0; }
.sheet--sm .sheet__actions { margin: 14px -24px 0; padding: 14px 24px; }

/* ------------------------------------------------------- the coffee sheet */

/* Two doors, one above the other, each a card that is a link: the whole card
   is the target, and the second line says who takes the money. The reader's
   own door comes first — the page's language is the best guess of which. */
.sponsor__opts { display: flex; flex-direction: column; gap: 10px; }
.sponsor__opt {
  display: block; padding: 12px 14px;
  border: 1px solid var(--border); border-radius: 10px;
  color: var(--text); text-decoration: none;
}
.sponsor__opt:hover { background: var(--surface-2); }
.sponsor__opt:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.sponsor__opt strong { display: block; font-size: 14px; }
.sponsor__hint { display: block; margin-top: 3px; color: var(--text-dim); font-size: 12.5px; line-height: 1.45; }
html:not([lang="zh-TW"]) #sponsor-kofi { order: -1; }
/* A door that is built but cannot take money yet says so on its own last
   line, in the red the page keeps for what a reader has to know before
   pressing — `--error` rather than the accent, which is too dim to read on
   a dark ground. */
.sponsor__pending { display: none; color: var(--error); }
.sponsor__opt--soon .sponsor__pending { display: block; }
/* The Ko-fi card's last line depends on whether the Taiwan door can be used:
   "use that one" once it can, "on its way" while it is hidden or standing
   open with payments not switched on. */
.sponsor__twsoon,
#sponsor-ecpay[hidden] ~ #sponsor-kofi .sponsor__seetw,
#sponsor-ecpay.sponsor__opt--soon ~ #sponsor-kofi .sponsor__seetw { display: none; }
#sponsor-ecpay[hidden] ~ #sponsor-kofi .sponsor__twsoon,
#sponsor-ecpay.sponsor__opt--soon ~ #sponsor-kofi .sponsor__twsoon { display: inline; }
/* Once the transfer door is up, it is the Taiwan option, and the Ko-fi card
   says so — over both of the lines above, which are about the ECPay door.
   The class goes on the container because the door sits after Ko-fi in the
   sheet, and a sibling selector only reaches forwards. */
.sponsor__seebank { display: none; }
/* Written against the same two IDs as the rules above, and after them, so
   it wins the tie rather than losing to their weight. */
.sponsor__opts--bank #sponsor-ecpay ~ #sponsor-kofi .sponsor__seetw,
.sponsor__opts--bank #sponsor-ecpay ~ #sponsor-kofi .sponsor__twsoon { display: none; }
.sponsor__opts--bank #sponsor-kofi .sponsor__seebank { display: inline; }
/* And once ECPay's own door stands in the link's place, the Taiwan door
   above takes cards again, and the Ko-fi card points up at it — over the
   transfer's line too, since one press beats a transfer made by hand. The
   same weight as the rules above, and after them, so it wins the tie. */
.sponsor__opts--ecpay #sponsor-ecpay ~ #sponsor-kofi .sponsor__seetw { display: inline; }
.sponsor__opts--ecpay #sponsor-ecpay ~ #sponsor-kofi .sponsor__twsoon,
.sponsor__opts--ecpay #sponsor-ecpay ~ #sponsor-kofi .sponsor__seebank { display: none; }

/* The transfer door is a button, not a link — what it opens is below, in
   this sheet — so it is talked back into the shape of the cards beside it.
   It stays last in Chinese too: the card doors are one press, this one is a
   transfer to make by hand. */
button.sponsor__opt {
  width: 100%; font: inherit; text-align: start; cursor: pointer;
  background: transparent; -webkit-appearance: none; appearance: none;
}
.sponsor__opt--open { order: 1; }
.sponsor__opt[aria-expanded="true"] { border-color: var(--accent); }

/* What the door opens: where to pay, then the word back that it was paid.
   The account leads, because it is the only part that works for a reader
   holding the phone they are reading on — a screen cannot scan itself. */
.bank { margin: 10px 0 4px; padding: 12px 14px; border: 1px solid var(--border); border-radius: 10px; }
.bank[hidden] { display: none; }
.bank__rows {
  margin: 0 0 10px; font-size: 13px;
  display: grid; grid-template-columns: max-content 1fr; gap: 4px 10px;
}
.bank__rows > div { display: contents; }
.bank__rows dt { margin: 0; color: var(--text-dim); }
.bank__rows dd { margin: 0; }
.bank .linkrow input[type="text"] { font: 600 14px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .04em; }

/* The codes are the shortcut, not the path: side by side under the account,
   each captioned with which app reads it, and each left out when the owner
   has no string for it. */
.bank__codes { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 14px; }
.bank__codes[hidden] { display: none; }
/* The picture is capped (.perk__qr already holds it to 200px and centres
   it); the caption is not, so a single code's line is not forced to break
   mid-word inside a 200px column. */
.bank__code { flex: 1 1 140px; margin: 0; }
.bank__code[hidden] { display: none; }
.bank__code .perk__qr { margin-bottom: 6px; }
.bank__code figcaption { text-align: center; text-wrap: pretty; }

/* ECPay's amount, in the transfer's frame: one field and a button, and what
   happens after paying said under them. In stage, a line in the red of the
   not-yet line (readable on the dark ground, as the accent is not) says
   which card to pay with, first, before anyone reaches for their own. */
/* It stands among the doors, straight under its own: the column's gap is the
   space above it, pulled in a little so it reads as that card's, not as a
   door of its own. */
.sponsor__opts .pay { margin: -4px 0 0; }
.pay .bank__fields { max-width: 320px; }
.pay__stage { margin: 0 0 10px; color: var(--error); }
.pay__stage[hidden] { display: none; }
.pay__help { margin: 6px 0 0; }

.bank__say { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border); }
.bank__say[hidden] { display: none; }
.bank__say > .hint { margin: 0 0 10px; }
.bank__fields { display: grid; gap: 4px 10px; margin-bottom: 10px; }
.bank__fields input { width: 100%; }
.bank__fields .lbl { margin-top: 4px; }
@media (min-width: 420px) {
  .bank__fields { grid-template-columns: max-content 1fr; align-items: center; }
  .bank__fields .lbl { margin-top: 0; }
}

/* A supporter's standing, said above the doors when there is one to say;
   a visitor's sheet has no such line. What just happened to it goes right
   under, and takes no room when there is nothing to say. */
#sponsor-heading:focus { outline: none; }
.perk__status { margin: 0 0 12px; font-size: 13px; line-height: 1.5; }
.perk__note { margin: -6px 0 12px; line-height: 1.5; }
.perk__note:empty { display: none; }
.perk__note[data-tone="error"] { color: var(--accent); }
/* Current or lapsed alike in the accent: the one is the thanks, the other
   the thing to do something about. */
.perk__status[data-status="active"],
.perk__status[data-status="expired"],
.perk__status[data-status="rejected"] { color: var(--accent); }
/* Carrying the standing over, under the status line: a button, and once
   pressed, the code to scan and the link to send. The picture keeps its own
   white ground and quiet zone (qr.js), so it reads in the dark theme too. */
.perk__handoff { margin: -4px 0 14px; }
.perk__handoff .perk__message:empty { display: none; }
.perk__handoffout { margin-top: 6px; padding: 12px; border: 1px solid var(--border); border-radius: 10px; }
.perk__handoffout > .hint { margin: 0 0 8px; line-height: 1.5; }
.perk__qr { width: min(200px, 100%); margin: 0 auto 10px; }
.perk__qr svg { display: block; width: 100%; height: auto; border-radius: 6px; }
.perk__handoffuntil { margin: 8px 0 0; line-height: 1.5; }
/* The box is narrower than the sheet, so the field gives way sooner than the
   sync sheet's and the copy button stays on its line on a phone. */
.perk__handoff .linkrow input[type="text"] { flex-basis: 120px; font-size: 12.5px; }
/* The code, under the doors: a field and a button on one line, and what
   the worker said under them. */
.perk__redeem { margin-top: 16px; }
.perk__redeem .lbl { display: block; margin-bottom: 6px; }
.perk__redeem input { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .04em; }
.perk__message { min-height: 1.4em; }
.perk__message[data-tone="error"] { color: var(--accent); }
/* The pretend row, off the site's host only: small, and plainly a tool. */
.perk__test {
  display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px;
  margin-top: 14px; padding-top: 10px; border-top: 1px dashed var(--border);
  font-size: 12px; color: var(--text-dim);
}
.perk__test .linkish { font-size: 12px; padding: 4px 8px; }
.perk__test .linkish.is-on { background: var(--surface-2); color: var(--text); }

.syncsec + .syncsec { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--border); }
.syncsec__title { margin: 0 0 4px; font-size: 14px; }
.syncsec__help { margin: 0 0 8px; line-height: 1.5; }
.syncsec__warn { margin-top: 8px; line-height: 1.5; }
.syncsec__status { margin: 0 0 10px; font-size: 13px; }
.syncsec__status:empty { display: none; }
.syncsec__age { color: var(--text-dim); font-size: 12px; margin-left: 8px; }
.syncsec__age:empty { display: none; }
.syncsec__actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.syncsec__actions .btn { padding: 9px 16px; font-size: 14px; }
/* Its face is written by the script — "link" or "reconnect" — so the phone
   rule that hides a chrome button's label does not apply in here. */
.syncsec__actions .btn span { display: inline; }

.linkrow { display: flex; gap: 8px; align-items: stretch; flex-wrap: wrap; }
.linkrow input[type="text"] {
  flex: 1 1 200px; min-width: 0; font-size: 13px; padding: 9px 12px;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  text-overflow: ellipsis;
}
.linkrow .linkish[disabled] { opacity: .5; cursor: default; }

.merge__body { margin: 0 0 8px; font-size: 14px; line-height: 1.5; overflow-wrap: anywhere; }

/* -------------------------------------------------------- the collect sheet */

/* One row per list, the row itself a checkbox: the library row's face, name
   and count, and at the end the box, ticked where the song already is. Every
   row takes a press — in or out — so every row shows a box. */
.collect { list-style: none; margin: 0 0 12px; padding: 0; max-height: 40vh; overflow-y: auto; }
.collect__empty { color: var(--text-dim); font-size: 13px; padding: 8px 2px; line-height: 1.5; }
.collect li + li { border-top: 1px solid var(--border); }
.collect__row {
  display: flex; align-items: center; gap: 10px; width: 100%;
  padding: 8px 6px; border: none; border-radius: 9px;
  background: none; color: var(--text); font: inherit; text-align: left; cursor: pointer;
}
.collect__row:hover { background: var(--surface-2); }
.collect__row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.collect__row[disabled] { cursor: default; opacity: .6; }
.collect__row[disabled]:hover { background: none; }
.collect__box {
  display: inline-flex; align-items: center; justify-content: center; flex: none;
  width: 22px; height: 22px; border-radius: 6px;
  border: 2px solid var(--text-dim); color: #fff; background: transparent;
}
.collect__box .icon { visibility: hidden; }
.collect__row:hover .collect__box { border-color: var(--accent); }
.collect__row[aria-checked="true"] .collect__box { border-color: var(--accent); background: var(--accent); }
.collect__row[aria-checked="true"] .collect__box .icon { visibility: visible; }
.collect__message { margin: 0 0 12px; min-height: 1.2em; }
.collect__message:empty { display: none; }
.collect__new input[type="text"] { font-family: inherit; }
#collect-create { padding: 9px 14px; font-size: 14px; }

/* ---------------------------------------------------------- the share sheet */

/* The message is prose, so it takes the page's face rather than the link
   field's monospace; it is meant to be read and rewritten, not copied out
   exactly. Tall enough for the whole of the default one. */
.share__text {
  display: block; width: 100%; min-height: 6.5em; margin: 0 0 12px;
  padding: 10px 14px; font: inherit; font-size: 14px; line-height: 1.5;
  color: var(--text); background: var(--surface); border: 1px solid var(--border);
  border-radius: 10px; resize: vertical; box-sizing: border-box;
}
.share__text:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
#share-help { margin: 0 0 12px; line-height: 1.5; }
/* The copy button is the sheet's one real action, and copies the message;
   its colour says so next to the link field it stands beside. */
#share-copy { padding: 9px 16px; font-size: 14px; }

/* ------------------------------------------------------------------ limits */

.limits { margin-top: 26px; padding: 16px 18px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); }
.limits h2 { margin: 0 0 8px; font-size: 14px; }
.limits ul { margin: 0; padding-left: 18px; color: var(--text-dim); font-size: 13px; }
.limits li { margin-bottom: 6px; }

/* Two groups under the one heading: what this site does, then what no web page
   can. The first is why anyone is here, so it reads in the page's own colour
   with a marked bullet, while the caveats keep the dimmer note voice. */
.limits__group { margin: 0 0 6px; font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--text-dim); }
.limits__list--can + .limits__group, .limits__perks + .limits__group { margin-top: 14px; }
.limits__list--can { color: var(--text); }
.limits__list--can li::marker { color: var(--accent); }
.limits__list--can b { font-weight: 600; }
/* What a supporter gets on top, under the list: one line, and the way in. */
.limits__perks { margin: 8px 0 0; padding-left: 18px; font-size: 13px; line-height: 1.6; color: var(--text-dim); }
.limits__perks b { color: var(--text); font-weight: 600; }
.limits__perks .linkish { font-size: 12px; padding: 3px 9px; vertical-align: baseline; }
/* What each browser offers against a caveat, under the caveat: a quieter
   second level, the browser's name leading each line. */
.limits .limits__how { margin: 4px 0 2px; padding-left: 16px; list-style: circle; }
.limits .limits__how li { margin-bottom: 3px; }
.limits__how b { color: var(--text); font-weight: 600; }

.foot { margin-top: 26px; color: var(--text-dim); font-size: 12px; text-align: center; }
.foot__build { opacity: .55; font-variant-numeric: tabular-nums; margin-left: 6px; }
/* The copyright rides right under the privacy note: same voice, one line,
   the maintainer's name a shade quieter than the claim itself. */
.foot--legal { margin-top: 6px; }
.foot__by { opacity: .7; margin-left: 6px; }
/* The address to write to, between the two: an envelope and the address
   itself, quiet until the pointer is on it. */
.foot--contact { margin-top: 6px; }
.foot__mail {
  display: inline-flex; align-items: center; gap: 5px;
  color: inherit; text-decoration: none; border-radius: 6px;
}
.foot__mail .icon { opacity: .75; }
.foot__mail:hover { color: var(--text); text-decoration: underline; }
.foot__mail:hover .icon { opacity: 1; }
.foot__mail:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
/* The privacy policy and the terms, last: links in the note's own voice. */
.foot--links { margin-top: 6px; }
.foot--links > span { margin: 0 6px; opacity: .55; }
.foot__link { color: inherit; text-decoration: none; border-radius: 6px; }
.foot__link:hover { color: var(--text); text-decoration: underline; }
.foot__link:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

.icon { display: block; pointer-events: none; }
[hidden] { display: none !important; }

/* ------------------------------------- privacy policy and terms of service */

/* Their own pages (privacy/, terms/): a column of prose at reading width,
   in the site's colours and type, both languages one after the other. */
.legal { max-width: 760px; }
.legal__nav { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; font-size: 13.5px; margin: 0 0 8px; }
.legal a { color: var(--accent); }
.legal__nav a { color: var(--text-dim); text-decoration: none; }
.legal__nav a:hover { color: var(--text); text-decoration: underline; }
.legal h1 { font-size: 26px; line-height: 1.3; margin: 28px 0 4px; }
.legal h2 { font-size: 18px; margin: 28px 0 8px; }
.legal h3 { font-size: 15.5px; margin: 20px 0 6px; }
.legal p, .legal li { line-height: 1.75; }
.legal ul { padding-left: 1.3em; }
.legal li + li { margin-top: 6px; }
.legal code { font-size: 13px; background: var(--surface-2); padding: 1px 5px; border-radius: 5px; }
.legal__date { color: var(--text-dim); font-size: 13px; margin: 0 0 16px; }
.legal article + article { margin-top: 56px; padding-top: 8px; border-top: 1px solid var(--border); }

/* ------------------------------------------- the always-on-top pip window */

.pip-body { margin: 0; background: #000; color: #fff; overflow: hidden; }
.pip-shell { position: relative; display: flex; flex-direction: column; height: 100vh; }
/* The picture takes everything the two lines of name below it do not, and the
   bar is drawn on top of it rather than under it — a window this small cannot
   afford a strip of chrome that is only wanted now and then. */
.pip-stage { position: relative; flex: 1 1 auto; min-height: 84px; }
.pip-mount { width: 100%; height: 100%; background: #000; }
.pip-mount iframe { width: 100%; height: 100%; border: 0; display: block; }
.pip-body .ctl { color: #fff; }
.pip-body .ctl:hover { background: rgba(255, 255, 255, .18); }

/* The bar over the picture, at the top, where the page's own bar sits in
   fullscreen and clear of the player's controls along the foot — which are
   the only way to scrub, and must stay pressable.

   It goes five seconds after the last thing the viewer did, and anything they
   do brings it back; the switch at its left end opts out and keeps it up, and
   it is the same switch, and the same remembered choice, as fullscreen's. */
.pip-bar {
  position: absolute; inset: 0 0 auto 0; z-index: 3; pointer-events: none;
  padding: 4px 6px 16px;
  background: linear-gradient(180deg, rgba(0, 0, 0, .82), rgba(0, 0, 0, 0));
  transition: opacity .3s ease;
}
/* The bar is a gradient with buttons in it, and only the buttons should take
   a press: the rest of it lies over the player's own top row, which stays the
   player's. Same shape as the page's bar in fullscreen, for the same reason. */
.pip-bar .controls { padding: 0; pointer-events: none; }
.pip-bar .ctl { pointer-events: auto; }
@media (prefers-reduced-motion: reduce) { .pip-bar { transition: none; } }

/* Hidden, it stops taking presses at all: an invisible button must not be
   worked by the press that only asked for the bar back. */
.pip-body.is-chrome-idle .pip-bar { opacity: 0; }
.pip-body.is-chrome-idle .pip-bar * { pointer-events: none; }
.pip-body.is-chrome-idle .pip-stage { cursor: none; }

/* What hears the pointer while the bar is away. The player is a cross-origin
   iframe and swallows every pointer over it, so without this the mouse could
   travel the whole picture unnoticed and the bar would never come back. It
   stops short of the player's own chrome along the foot, which stays the
   player's, and it only exists while the bar is hidden — the moment it is
   noticed it stands down, so the next press is the player's again. */
.pip-wake { position: absolute; inset: 0 0 42px 0; z-index: 1; display: none; }
.pip-body.is-chrome-idle .pip-wake { display: block; }

/* Under the picture: what this is, which list it came out of and where in the
   queue it sits — with the arrow to the queue itself at the bottom right. */
.pip-meta {
  flex: 0 0 auto;
  display: grid; grid-template-columns: minmax(0, 1fr) auto;
  align-items: end; gap: 6px; padding: 6px 8px 7px 10px;
}
.pip-meta__text { min-width: 0; }
.pip-title { margin: 0; font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pip-sub { margin: 2px 0 0; font-size: 11px; color: rgba(255, 255, 255, .62); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The glyph points up while the list is folded away and down once it is out,
   which is the direction pressing it moves the list. */
.pip-expand { width: 28px; height: 28px; }
.pip-expand .icon { transform: rotate(-90deg); transition: transform .18s ease; }
.pip-expand.is-on .icon { transform: rotate(90deg); }
@media (prefers-reduced-motion: reduce) { .pip-expand .icon { transition: none; } }

/* The queue, folded away until the arrow asks for it. Never a share of the
   picture's height: where the room for it comes from is worked out in app.js
   (sizePipForQueue), and it ends up in one of the two places below. */
.pip-queue {
  flex: 1 1 auto; min-height: 0;
  overflow-y: auto; overscroll-behavior: contain;
  margin: 0; padding: 0; list-style: none;
  border-top: 1px solid rgba(255, 255, 255, .14);
  background: rgba(255, 255, 255, .04);
}

/* Under the picture. The picture drops to its own 16:9 — that is the height
   the video really has at this width; anything more is black above and below
   it — and the list takes everything beneath: the padding the window had, and
   whatever the window grew by. A window dragged taller while the list is open
   hands the new height to the list too, the picture having no use for it. */
.pip-body.is-queue-below .pip-stage { flex: 0 0 auto; aspect-ratio: 16 / 9; min-height: 0; }

/* Over the picture: a window with no padding to spend, standing on the foot
   of the screen with nowhere to grow into, and which the page cannot move to
   make room (moveTo is refused for these windows). The video keeps its size
   and its place underneath and is back in sight the moment the list closes.
   Under the bar, which stays reachable over both. */
.pip-body.is-queue-over .pip-queue {
  /* Clear of the bar's own strip, so its buttons never sit on top of a row —
     and the band that leaves shows the top of the picture, which says plainly
     that the video is still there behind the list. */
  position: absolute; inset: 56px 0 0 0; z-index: 2;
  height: auto;
  background: rgba(10, 10, 12, .96);
}
/* Rows big enough to recognise a video by its picture, which is the whole
   reason a queue shows pictures at all — a 16:9 still the size of a fingernail
   is just a grey smudge in the margin. */
.pip-queue__btn {
  display: grid; grid-template-columns: 18px 96px minmax(0, 1fr);
  align-items: center; gap: 9px; width: 100%;
  padding: 5px 9px; border: 0; background: none; color: inherit;
  font: inherit; text-align: left; cursor: pointer;
}
.pip-queue__btn:hover { background: rgba(255, 255, 255, .1); }
.pip-queue__item.is-current .pip-queue__btn { background: rgba(255, 90, 90, .16); }
.pip-queue__num { font-size: 11px; color: rgba(255, 255, 255, .5); font-variant-numeric: tabular-nums; text-align: right; }
.pip-queue__item.is-current .pip-queue__num { color: #ff5a5a; }
.pip-queue__thumb { width: 96px; height: 54px; object-fit: cover; border-radius: 4px; background: #222; display: block; }
.pip-queue__text { min-width: 0; display: grid; gap: 1px; }
.pip-queue__title { font-size: 12px; line-height: 1.32; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pip-queue__channel { font-size: 10.5px; color: rgba(255, 255, 255, .5); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pip-queue__item.is-current .pip-queue__channel { color: #ff5a5a; }

/* The window is playing, silently: a browser gives no sound to a document the
   user has never interacted with. This veil is how that touch gets collected —
   the player is a cross-origin iframe, so a press on the video, the obvious
   place to aim, is never seen out here. Inert and invisible until sound is the
   thing that is missing, and the control bar below stays reachable either
   way. */
.pip-veil {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: grid;
  place-items: center;
  pointer-events: none;
  opacity: 0;
  visibility: hidden;
  transition: opacity .18s ease;
}
.pip-body.needs-sound .pip-veil {
  pointer-events: auto;
  opacity: 1;
  visibility: visible;
  cursor: pointer;
  background: rgba(0, 0, 0, .28);
}

.pip-prompt {
  margin: 0;
  padding: 7px 14px;
  border-radius: 999px;
  background: rgba(0, 0, 0, .78);
  color: #fff;
  font-size: 12px;
  font-weight: 600;
  white-space: nowrap;
}

/* Even the silent start was refused: draw the eye to the play button, which
   does work because pressing it inside this window carries its own
   activation. */
.pip-body.needs-play .ctl--primary {
  animation: pip-nudge 1.4s ease-in-out infinite;
}
@keyframes pip-nudge {
  0%, 100% { transform: scale(1); }
  50% { transform: scale(1.12); }
}
@media (prefers-reduced-motion: reduce) {
  .pip-body.needs-play .ctl--primary { animation: none; outline: 2px solid #fff; }
}

/*
 * iOS Safari zooms the whole page in when it focuses a form control whose
 * type is smaller than 16px, and it never zooms back out on its own. Tapping
 * any field cut off the right side of the page, and closing the sheet left
 * the main screen sitting at that zoom. The small type bought nothing on a
 * phone, so on a touch device every control a keyboard can open is 16px and
 * the zoom never fires. (The viewport tag deliberately still allows pinch
 * zoom -- that is the reader's to use, not ours to take away.)
 */
@media (pointer: coarse) {
  input[type="text"],
  input[type="number"],
  input[type="email"],
  input[type="search"],
  .search input,
  .lines .line__input,
  /* Named again: the smaller name field below is an id, and would outrank the
     element selector above it even in here. */
  #custom-name,
  .select {
    font-size: 16px;
  }
  /* Type that size wants its padding back with it. */
  #custom-name { padding: 10px 13px; }
  /* Type that tall needs a taller row, both so the field is not pressed
     against its own border and so the focus ring has its 3px to be drawn
     in. It lands on a 44px row, which is the size a finger wants anyway. */
  .lines { --line-h: 44px; }
  .lines .line__input { padding: 6px 10px; }
}

/* -------------------------------------------------------- player-only view */

/*
 * The phone's player-only view (see the section of the same name in app.js):
 * the video across the top of the screen, the queue under it, and nothing of
 * the chrome that is about the site rather than about what is playing.
 *
 * The switch for it is the first button of the page's control bar, and it is
 * a phone's alone: on a desktop the page is not the shape this view is for,
 * so the button is not drawn there and the class does nothing. The query is
 * the one-column layout's, and app.js watches the same one for the status
 * bar's colour; the two have to stay the same.
 */
[data-action="playermode"] { display: none; }

/* Drawn on a touch screen at any size as well: a tablet held sideways is
   wider than the one-column layout below, and the view still has something
   to offer there — the whole screen (see .stage.is-screen). */
@media (max-width: 900px), (max-height: 600px), (pointer: coarse) {
  [data-action="playermode"] { display: inline-flex; }
}

/* A supporter's desktop view is not here: its rules are handed out by the
   worker to a supporter's browser alone (server/desktop-player.js). */

@media (max-width: 900px), (max-height: 600px) {
  /* Everything behind the page goes black with the status bar: the canvas,
     and the body too, since a tablet's Safari paints the row above the page
     from the body's ground rather than from the theme colour app.js sets.
     The content keeps the page's own ground, held to the window so that
     black does not show under a short page either. */
  .is-player-mode, .is-player-mode body { background: #000; }
  .is-player-mode .main { background: var(--bg); }

  /* Edge to edge: the page's margin is what kept the video off the sides of
     the screen, and the video is the one thing here that wants them. The
     room at the foot is the queue's, so it is painted in the page's ground. */
  .is-player-mode .wrap { padding: 0; }

  /* Everything that is about the site rather than about what is playing. */
  .is-player-mode .top,
  .is-player-mode .banner,
  .is-player-mode .panel--library,
  .is-player-mode .limits,
  .is-player-mode .foot { display: none; }

  /*
   * The player stays at the top of the screen while the queue scrolls under
   * it, which is what pinning it to the window's top edge means once the
   * queue is longer than the screen. Sticky, not fixed: the player keeps its
   * place in the page, so the card never sees it scroll away and nothing
   * has to be measured to make room for it. That needs a container the
   * player can slide along — a one-column grid gives each row exactly its
   * item's height and leaves a sticky item nowhere to go — so the two blocks
   * left are stacked as a column instead. It sits over the rows it slides
   * along, and is painted so they do not show through the bar and title.
   *
   * The page scrolls, and the queue with it, on purpose: a tablet's Safari
   * folds its tab bar and address bar away only as the page itself is
   * scrolled, and a page that never scrolls is left with them for good
   * once they have been pulled down. In the page's fullscreen (below) it
   * is the other way round.
   */
  .is-player-mode .main {
    display: flex; flex-direction: column; align-items: stretch;
    gap: 6px; margin-top: 0; min-height: var(--window-h, 100svh);
  }
  /* Upright only. Sideways the stage takes the screen (below), and the page
     under it is not the point. */
  @media (min-height: 601px) {
    .is-player-mode .player { position: sticky; top: 0; }
  }
  /*
   * In the page's fullscreen the queue scrolls in a box of its own under
   * the player, the way a phone's own video app draws the list under a
   * video, and the page itself never scrolls. On an iPad, the browser's own
   * gesture for leaving fullscreen, made from a scrolled page, first ran
   * the page back to its top and showed a blank of the browser's own above
   * it on the way. A page that is never scrolled has no way back to run,
   * and the gesture leaves cleanly. The two blocks are a column the height
   * of the screen (--screen-h; the window's measures are stale in that
   * fullscreen), the player its own height and the queue the rest,
   * scrolling inside. app.js carries the queue's place across the switch
   * (see syncPageScreen).
   */
  .is-page-screen.is-player-mode body { overflow: hidden; }
  .is-page-screen.is-player-mode .main {
    height: var(--screen-h, var(--window-h, 100svh)); min-height: 0;
  }
  .is-page-screen.is-player-mode .panel--queue {
    flex: 1 1 auto; min-height: 0; overflow-y: auto;
    overscroll-behavior-y: contain; -webkit-overflow-scrolling: touch;
  }
  /*
   * Black with light type: one dark block with the video above it, the
   * way a phone's own video app draws the bar under a video. Also what
   * keeps the row above the page black wherever a browser lays its status
   * bar over the page (an iPad in fullscreen; an iPhone once the page runs
   * to the edge, which the view asks for — see syncPlayerMode in app.js):
   * the bar's height is the block's own padding, so what is under the bar
   * is the block's black however far the queue has scrolled. Pinned a
   * bar's height down instead, with the page's padding above it, the block
   * left a gap the queue's rows slid through, and the row came out light.
   * Tied to the view, not to the screen: switching with the screen turned
   * the block light again the moment fullscreen was left.
   */
  .is-player-mode .player {
    flex: none; z-index: 5;
    padding-top: env(safe-area-inset-top, 0px); padding-bottom: 6px;
    background: #000; color: #f2f2f4; border-bottom: 1px solid #34343a;
    --text: #f2f2f4; --text-dim: #9b9ba3; --surface-2: #27272b; --border: #34343a;
  }

  /*
   * The video's box, restated for a player with nothing above it and the bar
   * and title under it. Nothing above: the masthead is gone and the page's
   * padding with it, so the measured distance to the top — which, pinned,
   * would grow with every scroll — is replaced with the zero it really is.
   * Under: sideways, a video sized to the whole window would push the bar
   * off the bottom of the screen, and pinned there it could never be
   * scrolled to, so the box stops short of the window by what app.js
   * measures for the two. Upright, the width runs out long before that.
   */
  .is-player-mode #stage-slot {
    --stage-top: 0px;
    --stage-cap: max(calc(var(--window-h, 100svh) - var(--stage-under, 90px)), 60svh);
  }
  /* A screen-wide box has no corners to round off and no page to cast a
     shadow on. The floating card keeps its own shape; it is put away when
     the view goes on, but a scroll of the page below cannot bring it out. */
  .is-player-mode .stage:not(.is-mini) { border-radius: 0; box-shadow: none; }

  /* Off the screen's edges now that the page's padding is gone: the volume
     at the bar's end ran its thumb to the very edge. */
  .is-player-mode .controls { padding: 6px 12px 4px; }
  /* Off the screen's edge now that the page's padding is gone. */
  .is-player-mode .now { padding: 0 12px; }

  /*
   * The queue runs edge to edge too: no border, no rounding, and the panel's
   * padding comes down to what keeps a row's own rounded ends off the edge.
   * The heading and the shuffle switch's name go — what the list is and
   * what the switch does are plain enough from the rows and the glyph, and
   * on a phone the count and the switch want the line to themselves.
   */
  .is-player-mode .panel--queue { border: 0; border-radius: 0; padding: 8px 8px 24px; }
  .is-player-mode .panel--queue h2,
  .is-player-mode .shufflerow__label { display: none; }
  .is-player-mode .shufflerow__glyph { display: inline-flex; }
  /* With the heading gone the count led the line, sat on the panel's edge;
     set in a little instead. */
  .is-player-mode .panel--queue .panel__count { margin-left: 10px; }
  /* The whole list, scrolled by the page under the pinned player rather
     than in a box of its own: the scrollbar's lane goes with the box. */
  .is-player-mode .queue {
    max-height: none; overflow: visible; margin: 0; padding: 0;
    scrollbar-gutter: auto;
  }
}

/*
 * Sideways, the view lays the stage over the whole window: the geometry
 * fullscreen gives it, and the same bar over the picture. Set by app.js
 * (screenByUs), which also fades the bar on the same idle as fullscreen.
 * Stated apart from the :fullscreen rules above rather than joined to them —
 * see there for why those are written twice — and after the card's rules,
 * since the card's inline position must not win here. Real fullscreen, if
 * the bar's button brings it, lays its own rules over these. The player's
 * own controls stay the player's: the catcher over its fullscreen button is
 * drawn only in fullscreen, where that button has nothing left to do.
 */
/* Everything behind the stage goes black while it holds the screen, at any
   width: the row a tablet's Safari draws above the page takes the body's
   colour, and sideways a tablet is wider than the one-column layout the
   view's own black (above) is tied to. */
.is-screen-held, .is-screen-held body { background: #000; }

/*
 * Not laid over the page, but the page: everything else is put away and
 * the stage's own box is made the size of the screen, with the stage
 * filling it as it always does. Laying a fixed box over the window was
 * the first draft, and an iPad's Safari sized it short at both ends in
 * the page's fullscreen, where the window's measures are stale; a box in
 * the flow at the top of an otherwise empty page is wherever the page
 * begins, and its size comes from the screen (--screen-*, written by app.js
 * while the page holds the screen) before the window (--window-h).
 */
.is-screen-held body { overflow: hidden; }
.is-screen-held .wrap { padding: 0; }
.is-screen-held .wrap > :not(.main),
.is-screen-held .main > :not(.player),
.is-screen-held .player > :not(#stage-slot) { display: none; }
.is-screen-held .main { display: block; margin: 0; height: auto; min-height: 0; background: #000; }
.is-screen-held .player { position: static; padding: 0; border: 0; background: #000; }
.is-screen-held #stage-slot {
  width: var(--screen-w, 100vw); height: var(--screen-h, var(--window-h, 100svh));
  max-height: none; aspect-ratio: auto; margin: 0;
}
.stage.is-screen {
  position: absolute; inset: 0; z-index: 70;
  width: auto; height: auto; max-width: none;
  aspect-ratio: auto; border: 0; border-radius: 0; box-shadow: none;
}


/* Sideways on an iPhone the page lets go of the notch's edges (see
   syncPlayerMode in app.js), but Safari takes a moment over it: the first
   frames after the turn are still laid out to the edges, and the player's
   page inside its frame sets the picture in by the notch's width, though
   not its own title and buttons. No box for the frame puts both where they
   will be once the page is inside the safe area. Drawn where it was, the
   picture showed off to the right; drawn left by the inset, the picture was
   right and the title and buttons spread off the left edge, cut. So for
   those frames the frame is the width it will have, held at the stage's
   left: the picture is where it belongs, and the title and buttons, at
   their own width, only a notch to the left of theirs. Once the page is
   inside the safe area the insets are nothing, and so is this. */
.stage.is-screen-safe .player-mount {
  width: calc(100% - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px));
}

.stage.is-screen .mini-bar { display: grid; padding: 12px 16px; pointer-events: none; }
.stage.is-screen .mini-bar > * { pointer-events: auto; }
.stage.is-screen .mini-bar__title { display: none; }
.stage.is-screen .mini-bar .ctl { width: 44px; height: 44px; }
.stage.is-screen .mini-bar .ctl--primary { width: 52px; height: 52px; }
.stage.is-screen .mini-bar .controls--compact { column-gap: 10px; }
.stage.is-screen .mini-bar .controls__transport { gap: 10px; }
.stage.is-screen .mini-bar__group { gap: 14px; }
