/* styles.css — YouTube Queue
   Light/dark aware via prefers-color-scheme. Responsive, no horizontal scroll
   on mobile widths. */

:root {
  --bg: #f6f7f9;
  --surface: #ffffff;
  --surface-2: #f0f2f5;
  --text: #14171a;
  --text-dim: #5b6470;
  --border: #d9dee5;
  --primary: #cc0000;
  --primary-text: #ffffff;
  --accent: #065fd4;
  --watched: #0b8043;
  --not: #b23b3b;
  --skip: #5f6b7a; /* neutral slate for the card Skip button's active state */
  --focus: #1a73e8;
  --radius: 10px;
  --shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
  --shadow-hover: 0 8px 20px rgba(0, 0, 0, 0.16), 0 2px 6px rgba(0, 0, 0, 0.08);
  --maxw: 860px; /* readable width for onboarding panels / prose */
  --maxw-wide: 1320px; /* wide frame so the card grid shows several per row */
  --card-min: 400px; /* min card width before the grid wraps to fewer columns */
  /* Two-pane geometry. Declared here because the app frame's max-width is
     computed FROM them (see `body.app-active main`): the queue column, the
     gutter and the video's width have to be one set of numbers, or the frame
     and the grid disagree and the empty space they exist to remove reopens. */
  --queue-w: 400px; /* fixed queue column, right-hand side */
  --pane-gap: 20px; /* gutter between the player and queue panes */
  /* The width a 16:9 video reaches once its HEIGHT is what limits it — 420px is
     the chrome above and below it. Consumed ONLY by the frame's max-width: the
     cap sizes the player column to this, and .player then fills that column, so
     the video is height-limited wherever the cap binds and column-limited where
     it does not (below the cap's 1080px floor, where it grows to fill instead). */
  --video-w: calc((100dvh - 420px) * 16 / 9);
  --app-pad-x: 12px; /* side padding of the app view's main */
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #0f1113;
    --surface: #181b1f;
    --surface-2: #21262b;
    --text: #e8eaed;
    --text-dim: #9aa4af;
    --border: #2c333a;
    --primary: #ff4444;
    --primary-text: #ffffff;
    --accent: #6ea8fe;
    --watched: #34c759;
    --not: #ff6b6b;
    --skip: #64748b; /* neutral slate for the card Skip button's active state */
    --focus: #8ab4f8;
    --shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
    --shadow-hover: 0 8px 22px rgba(0, 0, 0, 0.6), 0 2px 6px rgba(0, 0, 0, 0.5);
  }
}

* {
  box-sizing: border-box;
}

html {
  -webkit-text-size-adjust: 100%;
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font: 16px/1.5 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial,
    sans-serif;
  overflow-x: hidden;
}

a {
  color: var(--accent);
}

kbd {
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 0 5px;
  font-size: 0.85em;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}

code.origin,
code {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 2px 6px;
  word-break: break-all;
}

/* Announced by a screen reader, never painted. Used for the curtain's live
   region: the overlay itself has no content, so covering/uncovering the screen
   would otherwise be the one state change with no non-visual signal at all.
   Not `display: none` / `visibility: hidden` — either would drop it out of the
   accessibility tree and silence the announcement. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  background: var(--surface);
  padding: 8px 12px;
  border-radius: var(--radius);
  z-index: 10;
}
.skip-link:focus {
  left: 8px;
  top: 8px;
}

/* ---- Layout ---- */

.topbar {
  max-width: var(--maxw-wide);
  margin: 0 auto;
  padding: 20px 16px 8px;
  text-align: center; /* title + tagline share one centre axis */
}
.topbar__title {
  margin: 0;
  font-size: 1.6rem;
}
.topbar__tagline {
  margin: 4px 0 0;
  color: var(--text-dim);
}

/* Title + page nav on ONE line. This lives on an inner row rather than on
   .topbar itself so .topbar stays a plain centred block: it remains a
   block-level flex ITEM of the body.app-active column (see the
   `body.app-active .topbar` rule), `text-align: center` keeps centring the
   tagline, and the tagline — a sibling of this row — can never be pulled onto
   the title's line. flex-wrap is the entire "stack when narrow" story, so no
   media query mentions the topbar. */
.topbar__row {
  display: flex;
  flex-wrap: wrap;
  /* baseline, NOT center: the 1.6rem title and the 0.95rem nav links are
     different sizes, so centring the BOXES puts their text on two different
     lines. The nested .topbar__nav-list needs nothing extra — a flex container
     reports its first item's baseline, so the <nav> already exposes the link
     text's baseline up to this row. */
  align-items: baseline;
  justify-content: center;
  gap: 2px 16px;
}
.topbar__nav-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
}
/* A hairline rule between items rather than a literal "|" glyph: a border has
   no text for a screen reader to announce between the link names. */
.topbar__nav-item + .topbar__nav-item {
  border-left: 1px solid var(--border);
}
/* Deliberately NOT .btn/.btn--ghost. A .btn is `padding: 8px 14px` -> a ~37px
   pill; three of those would add ~11px of chrome to EVERY page, including the
   height-locked app layout where the topbar's height comes straight off the
   video (.player is capped by calc((100dvh - 420px) * 16/9)). This box is ~27px
   — shorter than the 1.6rem title's line box — so the nav costs ZERO height on
   wide screens. The transparent bottom border reserves the current-page
   underline's space, so marking a page never reflows the row. */
.topbar__nav-link {
  display: inline-block;
  padding: 1px 12px;
  font-size: 0.95rem;
  color: var(--text-dim);
  text-decoration: none;
  border-bottom: 2px solid transparent;
}
a.topbar__nav-link:hover {
  color: var(--text);
}
.topbar__nav-link:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
  border-radius: 4px;
}
/* [aria-current='page'] is the ONLY current-page marker — no parallel
   .is-active class. .is-active in this codebase marks controls whose state
   changes at RUNTIME and is JS-toggled (updateSpeedButtons, setCardSpeed,
   updateLikeButton); a nav marker is static per document, so a second source of
   truth could only drift with nothing to sync it. It borrows the same --accent
   as the .is-active family, but as an underline rather than a fill: these are
   labels, not buttons. */
.topbar__nav-link[aria-current='page'] {
  color: var(--text);
  font-weight: 600;
  border-bottom-color: var(--accent);
}

main {
  max-width: var(--maxw-wide);
  margin: 0 auto;
  padding: 8px 16px 40px;
}

.footer {
  max-width: var(--maxw-wide);
  margin: 0 auto;
  padding: 16px;
  color: var(--text-dim);
  font-size: 0.85rem;
}

/* ---- Panels (onboarding) ---- */

.panel {
  max-width: var(--maxw); /* keep onboarding forms readable within the wide frame */
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 20px;
  box-shadow: var(--shadow);
}
.panel h2 {
  margin-top: 0;
}
.steps {
  padding-left: 20px;
}
.steps li {
  margin: 8px 0;
}
.origin {
  display: inline-block;
  margin-top: 4px;
}

.field {
  display: block;
  margin: 16px 0 8px;
}
.field__label {
  display: block;
  font-weight: 600;
  margin-bottom: 6px;
}
.field__input {
  width: 100%;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--surface-2);
  color: var(--text);
  font-size: 1rem;
}
.field__input:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}

.panel__actions {
  margin-top: 12px;
}

/* ---- Blocking error (fatal storage condition) ---- */
/* An opaque region IN FLOW below the header, not a fixed full-viewport scrim.
   Shown when startup must halt (DB blocked by another tab, IndexedDB
   unavailable, the stored queue unreadable, or another tab holding the page's
   lock). A scrim over the header left the page nav painted behind it: still
   focusable and still navigating on Enter, but invisible and unclickable — and
   the nav is one of only two working controls on this screen, since the halt
   comes before bindEvents(). Nothing behind needs covering: #app-main and both
   onboarding panels are already hidden by then. */
.blocked-overlay {
  display: flex;
  /* SAFE centring, not plain centring: once the panel is taller than the region
     (a long error under body.app-active, or a short window), a centred flex item
     overflows equally in both directions and the half above the scrollport is
     unreachable — scrollTop cannot go negative, so the heading is lost. `safe`
     falls back to start-alignment at exactly that point. */
  align-items: safe center;
  justify-content: center;
  padding: 16px;
  /* Claim most of the viewport, or the panel reads as a note tucked under the
     header rather than as the state of the page. */
  min-height: 60vh;
  background: var(--bg);
}
/* body.app-active is a 100dvh, overflow:hidden column, where a min-height is
   both too little (the panel sits high, with dead space below) and too much (a
   panel taller than the gap is clipped with nothing able to scroll to it). */
body.app-active .blocked-overlay {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
}
.blocked-overlay[hidden] {
  display: none;
}
.panel--blocked {
  width: 100%;
  max-width: 460px;
  margin: 0;
}

/* ---- Buttons ---- */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  padding: 8px 14px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--surface);
  color: var(--text);
  font-size: 0.95rem;
  text-decoration: none; /* anchors styled as buttons (e.g. YouTube ↗) */
  cursor: pointer;
  transition: background 0.12s ease, border-color 0.12s ease, opacity 0.12s ease;
}
.btn:hover:not(:disabled):not([aria-disabled='true']) {
  background: var(--surface-2);
}
/* 2px, not the 1px the text links (.row__title, .row__channel-link, .chan__link)
   ring at: those trace a run of glyphs, where a wider gap detaches the ring from
   its word, while a .btn is a filled box with its own border, and a ring 1px off
   that edge reads as a second border rather than a ring — worst on .btn--primary,
   where focus-blue sits 1px from a saturated red fill. Total reach is therefore
   4px, inside the 6px of headroom .workspace__player reserves for the controls
   in it above 1080px (below that it clips nothing); read the padding/margin pair
   there before widening this further. */
.btn:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
/* A POINTER press placed focus here, so no ring — app-wide, over EVERY outline
   ring in this file (page-chrome's markPointerFocus owns the class). Chrome
   flips a mouse-focused element to :focus-visible on the next keydown, any
   keydown, so without this a clicked button rings from the first keypress while
   the selection verdict — frozen when focus landed — still refuses it, and a
   ringed control that arms nothing is exactly the contradiction the ring is
   there to prevent. The suppression must be EXPLICIT: narrowing each ring rule
   with :not() drops only the author ring and hands Chrome's own back (measured:
   outline: auto 1px). The two row rings are box-shadows and so pass through
   untouched, which is what leaves .row--pointed and .chan--pointed free to ring
   a placement press on purpose. Text fields are the one exemption — a caret is
   not the field's boundary, and an input arms no shortcut anyway (the typing
   guard), so it has no contradiction to fix. That :not() also lifts this to
   0,3,0, above every ring rule here, so it need not come last in the file. */
.is-pointer-focused:focus-visible:not(.field__input) {
  outline: none;
}
.btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
/* IN-FLIGHT, not unavailable: the control keeps focus, because disabling one
   under the user's own focus drops that focus to <body>. Its re-entry guard is
   the page's own state flag, not the DOM. Must look identical to :disabled, or
   "busy" is invisible. */
.btn[aria-disabled='true'] {
  opacity: 0.5;
  cursor: not-allowed;
}
.btn--primary {
  background: var(--primary);
  border-color: var(--primary);
  color: var(--primary-text);
}
/* Must carry the base .btn:hover rule's :not([aria-disabled]) too, or that
   rule outweighs this one and paints a hovered red button --surface-2 --
   white --primary-text on near-white. */
.btn--primary:hover:not(:disabled):not([aria-disabled='true']) {
  filter: brightness(1.05);
  background: var(--primary);
}
.btn--ghost {
  background: transparent;
  border-color: transparent;
  color: var(--text-dim);
}
.btn--ghost:hover:not(:disabled) {
  background: var(--surface-2);
}
.btn--skip {
  border-color: var(--border);
  color: var(--text-dim);
}
.btn--skip:hover:not(:disabled) {
  background: var(--surface-2);
}
/* An author `display` beats the UA [hidden] rule, so every hideable button
   needs this — sign-in/sign-out, "Start the stash", and any future one. */
.btn[hidden] {
  display: none;
}

/* ---- Toolbar ---- */

.toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 12px;
}
.toolbar__auth,
.toolbar__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
}
.auth-status {
  font-size: 0.9rem;
  color: var(--text-dim);
  padding-right: 4px;
}
.auth-status.is-signed-in {
  color: var(--watched);
  font-weight: 600;
}

/* ---- Stats ---- */

.stats {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin: 0 0 12px;
}
.stats__item {
  flex: 1 1 140px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 10px 14px;
}
.stats__item dt {
  font-size: 0.78rem;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--text-dim);
}
.stats__item dd {
  margin: 4px 0 0;
  font-size: 1.15rem;
  font-weight: 700;
}

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

.status {
  padding: 10px 14px;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--surface);
  margin-bottom: 12px;
}
.status--progress {
  border-color: var(--accent);
}
.status--success {
  border-color: var(--watched);
}
.status--error {
  border-color: var(--not);
  color: var(--not);
}

/* ---- Privacy curtain ---- */

/* A full-viewport solid overlay (BSOD-style blue, no content) that slides DOWN
   to cover the page and UP to lift. Sits above everything, so while covering it
   blocks all interaction with the page behind it. That deliberately includes the
   fatal-error overlay and the toasts (1000): one gesture must hide the whole
   screen, no exceptions — and it is recoverable, since Esc (the panic key,
   handled before every guard) and scroll-up both lift it. Hidden by default via
   translateY(-100%) — still present (not display:none) so it can
   animate and so a wheel event over it targets the curtain, not the page. */
.curtain {
  position: fixed;
  inset: 0;
  z-index: 2000;
  background: #0078d7;
  transform: translateY(-100%);
  transition: transform 0.35s ease;
  pointer-events: none; /* lifted: let events reach the page */
}
.curtain.is-covering {
  transform: translateY(0);
  pointer-events: auto; /* covering: intercept all pointer/wheel events */
}

/* ---- Toasts (top-right notifications) ---- */

.toasts {
  position: fixed;
  top: 12px;
  right: 12px;
  z-index: 1000; /* above everything, including the onboarding panels */
  display: flex;
  flex-direction: column;
  gap: 8px;
  width: min(360px, calc(100vw - 24px));
  pointer-events: none; /* clicks pass through the gaps between toasts */
}
.toast {
  pointer-events: auto;
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-left-width: 4px;
  border-radius: 8px;
  background: var(--surface);
  color: var(--text);
  box-shadow: var(--shadow-hover);
  font-size: 0.9rem;
}
.toast__text {
  flex: 1 1 auto;
  min-width: 0;
  overflow-wrap: anywhere;
}
.toast--progress {
  border-left-color: var(--accent);
}
.toast--success {
  border-left-color: var(--watched);
}
.toast--error {
  border-left-color: var(--not);
}
.toast--info {
  border-left-color: var(--text-dim);
}
.toast__close {
  flex: 0 0 auto;
  border: 0;
  background: none;
  color: var(--text-dim);
  cursor: pointer;
  font-size: 1.15rem;
  line-height: 1;
  padding: 0 2px;
}
.toast__close:hover {
  color: var(--text);
}

/* ---- Shortcuts ---- */

.shortcuts {
  color: var(--text-dim);
  font-size: 0.8rem;
  margin: 0 0 8px;
  /* Keep the full shortcut list on ONE row on normal widths; wrap only when
     genuinely too narrow (vertical space is precious — it shrinks the player). */
  white-space: normal;
}

/* ---- Queue list ---- */

.queue {
  list-style: none;
  margin: 0;
  /* Gutter so card borders/shadows/rings aren't clipped at the pane edges, plus a
     little top gap below the sticky header. */
  padding: 10px 12px 12px;
  /* ONE card per row in the (independently scrolling) left pane. */
  display: flex;
  flex-direction: column;
  gap: 16px;
}
/* The ONE place in the app where focus draws no ring, and deliberately. The
   list carries tabindex="-1" purely so "Skip to queue" can move focus (and
   with it a screen reader's cursor) onto the labelled region — it is a
   landing, not a control, and never a tab stop. The UA outline would frame
   the whole list, which runs thousands of pixels tall and is clipped by the
   pane, so it reads as a rendering fault rather than a position. Nothing is
   lost: the very next Tab or ArrowDown lands on a card, which rings normally. */
.queue:focus,
.empty:focus {
  outline: none;
}
/* An author `display` beats the UA [hidden] rule, so anything hideable needs
   its own [hidden] { display: none } — without this the empty state leaves a
   padding box and an empty list in the accessibility tree. */
.queue[hidden] {
  display: none;
}

.row {
  /* YouTube-style vertical card: a full-bleed 16:9 thumbnail on top, then the
     title/channel/time body, then the actions footer. */
  display: flex;
  flex-direction: column;
  width: 100%;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  /* Clears the OPAQUE sticky .queue-header: focus() scrolls a card's
     scroll-margin box into view, so without this a keyboard-focused card parks
     underneath it. On the CARD rather than scroll-padding on the pane because
     the scrollport changes with the layout — .workspace__queue above 1080px, the
     document below it, which needs its own taller value (see the media query). */
  scroll-margin-top: 60px;
  /* No overflow:hidden here — corner-clipping lives on the thumbnail wrapper, so
     the card's own border / shadow / rings render fully uncut. */
  transition: box-shadow 0.12s ease, border-color 0.12s ease;
}
/* Hover: soft lift (bigger shadow) + a tinted border. No transform, so nothing
   moves; border WIDTH is unchanged (colour only), so there is no layout shift. */
.row:hover {
  box-shadow: var(--shadow-hover);
  border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
}
/* Focus / selection: an even focus-coloured ring with a small gap, via
   box-shadow so it follows the card radius uniformly on all sides and never
   shifts layout. Distinct from the now-playing fill; the transparent outline
   keeps it visible in forced-colors mode.
   TWO selectors, because a .row is not a control you activate — it is the
   cursor position for ArrowUp/ArrowDown, x, t and 1/5/2 (see initQueueFocus in
   page-chrome.js) — and only SOME of the ways focus reaches one are the user
   choosing a place. :focus-visible covers keyboard focus and the programmatic
   focus that states it (page-chrome's focusByGesture, which passes focusVisible
   from the gesture in flight rather than leaving it to a heuristic Chrome
   revises on the next keydown); .row--pointed covers the one pointer gesture
   that IS a placement, a click on the card's own body. What plain :focus would
   also have caught, and must not: the app rescuing focus onto a card after a
   MOUSE press on a control that disabled itself (Clean up, Trim front, Show
   all, the removal rescue) — a place the user never chose and does not need
   shown. Not :focus-within either — clicking Play, Skip or the card menu
   focuses that control and leaves the card unringed.
   Must stay AFTER .row:hover: the :focus-visible half is an identical 0,2,0 and
   both set box-shadow, so source order alone decides it, and clicking a card
   necessarily hovers it. (The .row--pointed half is 0,3,0 and wins outright.) */
.row:focus-visible,
.row.row--pointed:focus {
  outline: 2px solid transparent;
  outline-offset: 2px;
  box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--focus), var(--shadow);
}
/* Focus stayed on a card the pointer just DESELECTED, so the ring must not come
   back: a press on the thumbnail of an already-focused card unmarks it, and
   ui.js then re-focuses the row focus is already on — a no-op, which is the one
   route by which focusVisible cannot be stated (the option only applies when
   focus actually moves). Chrome's flip on the next keydown would otherwise ring
   a card the shortcuts refuse. Restores the resting shadow explicitly rather
   than narrowing the rule above, which would hand the ring back; page-chrome's
   pointerdown owns the class and the next focusin clears it. */
.row.row--unringed:focus-visible {
  box-shadow: var(--shadow);
}

.row__thumb-btn {
  display: block;
  position: relative; /* THE 16:9 box; anchors the img + duration/SHORTS/play overlays */
  width: 100%;
  aspect-ratio: 16 / 9; /* box is exactly 16:9 at the column width (~400x225) */
  padding: 0;
  border: 0;
  /* Nest inside the card's 1px border (radius - border) so the accent ring/border
     hugs the thumbnail with NO corner gap. */
  border-radius: calc(var(--radius) - 1px) calc(var(--radius) - 1px) 0 0;
  background: var(--surface-2);
  overflow: hidden; /* clips the image/overlays to the rounded top corners */
  line-height: 0;
  cursor: pointer;
  text-decoration: none; /* it's an <a> now — kill link underline */
  color: inherit; /* …and link coloring, so it reads identical to the old button */
  transition: opacity 0.12s ease, filter 0.12s ease;
}
.row__thumb-btn:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: -2px;
}
.row__thumb {
  /* Fill the 16:9 box and show the ENTIRE image (contain) — never cropped in any
     direction. A true 16:9 source fits the 16:9 box exactly with no bars. */
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
}

/* Thumbnail overlays are ABSOLUTE, so they never change card height. */
.row__duration {
  position: absolute;
  right: 6px;
  bottom: 6px;
  padding: 1px 4px;
  border-radius: 4px;
  background: rgba(0, 0, 0, 0.8);
  color: #fff;
  font-size: 0.75rem;
  font-weight: 600;
  line-height: 1.35;
  font-variant-numeric: tabular-nums;
  pointer-events: none;
}
.row__shorts {
  position: absolute;
  left: 6px;
  top: 6px;
  padding: 2px 6px;
  border-radius: 4px;
  background: var(--primary);
  color: var(--primary-text);
  font-size: 0.66rem;
  font-weight: 800;
  letter-spacing: 0.04em;
  line-height: 1.2;
  pointer-events: none;
}
.row__live {
  position: absolute;
  right: 6px;
  top: 6px;
  padding: 2px 6px;
  border-radius: 4px;
  background: rgba(0, 0, 0, 0.8);
  color: #fff;
  font-size: 0.66rem;
  font-weight: 800;
  letter-spacing: 0.04em;
  line-height: 1.2;
  pointer-events: none;
}
.row__live--on {
  background: #cc0000;
}

/* Play (▶) overlay — revealed on hover of the thumbnail button. */
.row__play {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.28);
  opacity: 0;
  transition: opacity 0.12s ease;
  pointer-events: none;
}
.row__thumb-btn:hover .row__play {
  opacity: 1;
}
.row__play-icon {
  width: 54px;
  height: 54px;
  border-radius: 50%;
  background: rgba(0, 0, 0, 0.78);
  color: #fff;
  display: grid;
  place-items: center;
  font-size: 22px;
  line-height: 1;
  padding-left: 3px; /* optically centre the ▶ glyph */
}

.row__meta {
  min-width: 0;
  flex: 1 1 auto; /* grow so the actions footer sits at the bottom of the card */
  padding: 12px 14px 6px;
  transition: opacity 0.12s ease;
}
.row__title {
  font-weight: 600;
  text-decoration: none;
  color: var(--text);
  /* Full title, wrapping to as many lines as needed; break overlong unbroken
     tokens so they can't overflow the card width. */
  overflow-wrap: anywhere;
}
.row__title:hover {
  color: var(--accent);
}
/* The house ring, in place of the UA default. The radius shapes the outline
   only (the link has no background or border of its own), softening it where a
   long title wraps. renderPlayerTitle (js/ui.js) gives the now-playing title
   this same class so the card and the player bar cannot drift — which also puts
   this ring inside .workspace__player, whose scrollport is grown to fit it. */
.row__title:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
  border-radius: 4px;
}
/* ONE in-flow item — .row__subtext — over an avatar lifted OUT of the flow and
   pinned to this box (see .row__sub .row__avatar). In flow the avatar sits on
   the name's FIRST line, where it can neither centre against a name that has
   wrapped nor leave the date room to follow that name on the same line. Out of
   flow it stops contributing this row's height and left indent, so both are
   restored by hand. align-items centres a one-line badge against the avatar;
   a wrapped one is the taller of the two and the avatar centres against IT. */
.row__sub {
  position: relative;
  /* The query container the date's one exception is asked about (see
     .row__time-abs). inline-size only, so this row still grows with its own
     content; `contain: layout` comes with it and changes nothing, the row being
     the avatar's containing block already. */
  container: metarow / inline-size;
  margin-top: 4px;
  color: var(--text-dim);
  font-size: 0.85rem;
  display: flex;
  align-items: center;
  min-height: 36px; /* the avatar's own; nothing else sets it now */
  padding-left: 42px; /* the 36px it vacated, plus the 6px gap after it */
}
.row__channel {
  font-weight: 500;
  /* .row__title's rule, on the other text in the card that can be one long
     unbroken token: a channel name with no spaces (or a single word wider than
     the column) breaks rather than running off the card. */
  overflow-wrap: anywhere;
}
/* Everything but the avatar — channel name, separator, date — as ONE block of
   ordinary inline content, shared by a card and the now-playing bar. Inline is
   the whole point: the name wraps to as many lines as it needs, each of them
   indented past the avatar by the row's own padding, and the date follows the
   last one instead of being pushed to a line of its own the way a flex item was. */
.row__subtext {
  flex: 1 1 auto;
  min-width: 0;
}
/* The anchor to the channel on YouTube (card + player) holds the avatar AND the
   name, so clicking either navigates — but only the NAME is in flow inside it,
   so the link is exactly as wide as its text and the empty space beside a short
   name belongs to the card. Plain inline, with no box of its own, which is what
   lets a long name wrap inside it.
   Keep the muted inline look — inherit the sub-line colour rather than the
   bright accent link colour — and reveal the affordance the way .row__title
   does: hover turns the name accent, focus draws a ring, nothing underlines. */
.row__channel-link {
  color: inherit;
  text-decoration: none;
}
.row__channel-link:hover .row__channel {
  color: var(--accent);
}
/* .row__title's ring exactly, on the link that behaves like it: OUTSET at the
   text links' 1px offset, at the corner radius rather than a pill — this box
   goes tall when the name wraps, and 999px would then make end caps of half that
   height, curving back through the text. It fragments around the NAME's line
   boxes and nothing else: an out-of-flow avatar contributes no geometry to them. */
.row__channel-link:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
  border-radius: 4px;
}
/* The separator already carries a space on each side (metaSeparator in js/ui.js,
   which is where the reason for those is), so this margin is only the rest of
   the 12px the name and the date were held apart by as flex items. */
.row__dot {
  opacity: 0.6;
  margin: 0 8px;
}
/* One unit: inline flow would otherwise break the date across its OWN spaces
   ("24 Feb / 2026, 17:00") once a long channel name leaves it too little room.
   The line is meant to break at the space BEFORE the separator instead, which
   is why that is the only breakable one of the pair — and it stays that way at
   every width a date can actually fit, which is what the container query below
   is scoped so tightly for. */
.row__time-abs {
  white-space: nowrap;
}
/* THE ONE WIDTH AT WHICH IT MAY BREAK: a column too narrow to hold the date on a
   line of its own, where nowrap keeps nothing whole — it just runs the date off
   the card and the page's right edge with it (a 175px viewport put the box at
   x = 207). Wrapped at its own spaces is the least-bad reading there.
   A CONTAINER query, not a media query, because the question is about this column
   rather than the viewport: the container is .row__sub (see its container-type)
   and a size query reads its CONTENT box — exactly the width left after the
   avatar's indent, on either page and in either layout.
   150px is the widest a short-month timestamp runs across locales ("Sep 24, 2026,
   05:00 PM" and the like) plus slack, and erring HIGH is the safe direction: above
   the true threshold the cost is a date split over two lines, below it a page that
   scrolls sideways. Neither two-pane layout can reach it — the 400px queue pane
   leaves 331px here. The non-breaking space still holds inside the query, so the
   "·" travels with the date's first line and does not dangle. */
@container metarow (max-width: 150px) {
  .row__time-abs {
    white-space: normal;
  }
}
/* The bar needs no threshold: there "· date" is ONE atomic box (renderPlayerMeta
   in js/ui.js), so a date that does not fit moves WHOLE to the next line, dot
   and all — below the avatar, where a line is the row's full width — and wraps
   at its own spaces only when even that cannot hold it. Exact at every width
   and in every locale, where a threshold has to guess the widest label
   ("Scheduled …" in pt-BR runs 227px). `top` keeps the dot on a wrapped date's
   FIRST line; an inline-block's baseline is its last. */
.player__date {
  display: inline-block;
  max-width: 100%;
  vertical-align: top;
}
.player__date .row__time-abs {
  white-space: normal;
}

/* Channel avatar (always present — image or letter placeholder — so the sub-row,
   and therefore the card, keeps a uniform height in every state). */
.row__avatar {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  object-fit: cover;
  background: var(--surface-2);
  flex: 0 0 auto; /* never grow/shrink where it IS a flex item (.chan__link) */
}
.row__avatar--placeholder {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 1px solid var(--border);
  color: var(--text-dim);
  font-size: 1.05rem;
  font-weight: 700;
  line-height: 1;
  text-transform: uppercase;
}
/* In a card's meta row and in a channel row alike, it is out of flow, pinned to
   the row's left edge
   and centred on the row's FULL height, which is what centres it against a name
   that has wrapped. It stays a DESCENDANT of .row__channel-link, so a click on
   it still follows the link: out of flow is a geometry exemption, not a
   hit-testing one. (position blockifies the placeholder's inline-flex to flex,
   so its letter stays centred.) The now-playing bar floats it instead; see
   .player__meta. */
.row__sub .row__avatar,
.chan__identity .row__avatar {
  position: absolute;
  left: 0;
  top: 50%;
  transform: translateY(-50%);
}

/* STRETCH, not center: nothing here agrees on its own height. The base .btn
   computes `line-height: normal` — the UA's `font` shorthand on <button> beats
   the body's 1.5 — so even Play and Skip differ by whichever fallback font draws
   ▶ or ↗, and .btn--cardspeed / .btn--menu each add their own smaller padding and
   line-height on top. Left to size themselves the six came out 26.16–38px and
   every hover fill and focus ring traced a different box. Stretching hands all of
   them the tallest one's height, and the footer is exactly as tall as before,
   since that item already set it. .row__speeds and .row__menu are containers, so
   each has to pass the stretch on to its own children. */
.row__actions {
  display: flex;
  flex-direction: row;
  /* WRAPS, and only when it has to: the six controls stop fitting one line below
     ~412px of viewport, where a nowrap footer overflowed the card and, through it,
     the whole page. The two-pane layout's 400px queue pane is clear of that, so
     the footer there is unwrapped and unchanged. Wrapping costs nothing:
     `flex: 1 1 0` shares each LINE equally, and align-items works per line too,
     so each line is as tall as its own tallest control. */
  flex-wrap: wrap;
  align-items: stretch;
  gap: 8px;
  padding: 8px 14px 14px;
}
/* Cohesive, understated buttons: ghost (transparent border, no fill) at rest;
   a subtle neutral fill on hover; Play tinted with the accent on hover, Skip
   staying neutral. */
.row__actions .btn {
  border-color: transparent;
  background: transparent;
  color: var(--text-dim);
}
/* Skip takes a FIXED slice of the footer, so it is the same width with or without
   the speed group (i.e. on non-embeddable cards too); ▶ Play / ↗ YouTube grow into
   everything else, pushing the speed group hard right against Skip.
   NOWRAP, or a label's min-content is its longest WORD and a footer a few px
   short breaks "▶ Play" over two lines — stretching the whole row to match —
   instead of wrapping a control onto the next line. The 10px side padding (base
   .btn: 14px) is what lets the 400px pane hold every label whole: at 14px the
   stash's "Remove" outgrows Skip's slice (min-width: auto) and the line no
   longer fits. Ghost boxes, so the padding is a floor, not a visible edge. */
.row__actions > .btn {
  flex: 1 1 0;
  padding-inline: 10px;
  white-space: nowrap;
}
.row__actions > .btn--skip {
  flex: 0 0 31.5%; /* ≈ the width Skip had when it split the footer with Play */
}
/* A footer that also carries the card menu has ~40px less to share (trigger +
   gap) inside a queue pane fixed at 400px, so it tightens its own gaps a little
   and Skip gives back the rest — rather than squeezing ▶ Play below its label
   width, which (nowrap, min-width: auto) would wrap the footer onto two lines.
   Both rules are scoped with :has, so a footer that renders no menu keeps its
   exact previous proportions. */
.row__actions:has(.row__menu) {
  gap: 6px;
}
.row__actions:has(.row__menu) > .btn--skip {
  flex: 0 0 calc(31.5% - 22px);
}
.row__actions .btn:hover:not(:disabled) {
  background: var(--surface-2);
  color: var(--text);
}
.row__actions .btn--play:hover:not(:disabled) {
  background: color-mix(in srgb, var(--accent) 12%, transparent);
  color: var(--accent);
}
.btn--icon {
  padding: 7px 10px;
  font-size: 1.05rem;
  line-height: 1;
}
/* Per-video preferred-speed group: compact, tightly grouped, with a subtle
   divider before the Skip button. */
.row__speeds {
  /* Shrinkable and WRAPPING, for the same reason .row__actions is: three fixed
     36px buttons are ~119px the footer cannot always give them, and an
     unshrinkable group carried the card, and the page with it, off the right edge
     below ~170px of viewport. It costs nothing above that — the group sits at its
     max-content width wherever the footer has the room for it. */
  flex: 0 1 auto;
  display: flex;
  flex-wrap: wrap;
  /* Stretched by the footer, and stretching in turn, so the three buttons are
     the footer's height too. The divider is this box's border, so it now spans
     the footer's full content height — per LINE once it wraps, as everywhere
     else here. */
  align-items: stretch;
  gap: 2px;
  padding-right: 6px;
  margin-right: 2px;
  border-right: 1px solid var(--border);
}
/* One fixed width for all three, so they stay equal and the widest label ("1.5×")
   never clips. The width sets the box; the HEIGHT comes from the footer's stretch,
   so the padding only holds a floor under it. */
.btn--cardspeed {
  flex: 0 0 auto;
  width: 36px;
  padding: 6px 0;
  font-size: 0.76rem;
  line-height: 1;
}
/* Active preference uses the SAME deep-blue accent as the player speed/like buttons. */
.row__actions .btn--cardspeed.is-active {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--primary-text);
}
.row__actions .btn--cardspeed.is-active:hover:not(:disabled) {
  /* Keep the glyph light on hover; only nudge the fill a touch darker. */
  background: color-mix(in srgb, var(--accent) 90%, #000);
  color: var(--primary-text);
}

/* ---- Card menu ---- */

/* A WRAPPER, not a bare .btn child of the footer: `.row__actions > .btn` takes
   an equal flex share, which would squeeze ▶ Play. It is also the panel's
   positioning context, so the panel travels with the card as the pane scrolls
   (hence no close-on-scroll anywhere). */
.row__menu {
  position: relative;
  /* A flex container purely so the trigger fills the height the footer stretches
     this wrapper to. The panel is out of flow, so it is no flex item and its
     bottom/right offsets are untouched. */
  display: flex;
  flex: 0 0 auto;
  /* LAST ON ITS LINE, wherever the footer wraps: the panel is right-aligned to
     this wrapper, so a trigger sitting at the START of a wrapped line hangs its
     160px panel off the card's left edge (measured at x = -89 on a 360px
     viewport). Free on an unwrapped line — ▶ Play grows, so a flex line here
     never has free space left for an auto margin to absorb. */
  margin-left: auto;
}
/* Compact trigger. This sets its WIDTH only — the height arrives from the
   footer's stretch, so the padding is just a floor under it and the base .btn
   centres the glyph in whatever height it gets. Its ghost look comes from the
   `.row__actions .btn` rule above. No
   font-family: U+00B7 is Latin-1 and present in every font a button can fall
   back to, Arial included, so the trigger renders in the same default face as
   ▶ Play and ↗ YouTube instead of being the one control that names its own. */
.btn--menu {
  width: 32px;
  padding: 6px 0;
  font-size: 1.05rem;
  line-height: 1;
}
/* The trigger’s glyph lives HERE, not in ui.js: it is pure decoration, and the
   button carries no text content at all, so nothing leaks into the accessible
   name that aria-label="More actions" computes. Written as CSS escapes rather
   than literal bytes — this is the stylesheet’s only `content`, and an escape
   cannot be mangled by a charset mismatch. \B7 is U+00B7 MIDDLE DOT, three of
   them; the escape ends at the next \, so no separator is needed. */
.btn--menu::before {
  content: "\B7\B7\B7";
}
/* Opens UPWARD, right-aligned. The queue pane clips (overflow-y: auto) and this
   footer is the card's LAST element, so upward keeps the panel over the card's
   own body and inside the pane — including on the last card. z-index clears the
   sticky .queue-header (5) and stays far below the curtain (900). */
.row__menu-panel {
  position: absolute;
  bottom: calc(100% + 6px);
  right: 0;
  z-index: 6;
  min-width: 160px;
  padding: 4px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  box-shadow: var(--shadow-hover);
}
/* An author `display` beats the UA [hidden] rule (see CLAUDE.md) — without this
   the "hidden" panel stays on screen. The trigger is a .btn, so the general
   .btn[hidden] rule already covers it if it ever becomes hideable. */
.row__menu-panel[hidden] {
  display: none;
}
.row__menu-item {
  display: block;
  width: 100%;
  padding: 8px 10px;
  border: 1px solid transparent;
  border-radius: 6px;
  background: transparent;
  color: var(--text);
  font: inherit;
  font-size: 0.9rem;
  text-align: left;
  white-space: nowrap;
  cursor: pointer;
}
.row__menu-item:hover:not(:disabled) {
  background: var(--surface-2);
}
/* An item is NOT a .btn (it has none of that class's box), so `.btn:disabled`
   misses it — same dimming, spelled out here. */
.row__menu-item:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
.row__menu-item:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: -2px;
}

/* ---- Drag-to-reorder (stash) ---- */

/* The positioning context for the grip and the drop line. No z-index, so the
   card makes no stacking context and the menu panel still clears the sticky
   header exactly as before. */
.row--reorderable {
  position: relative;
}
/* Top-CENTRE of the thumbnail, clear of SHORTS (left) and UPCOMING/LIVE
   (right), in the same dark badge style. Shown on hover only: it is a mouse
   affordance, and the keyboard has the menu and the Shift keys. The glyph is a
   `content` like the menu trigger's (U+283F, a 6-dot grip). */
.row__grip {
  position: absolute;
  top: 6px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 1;
  padding: 2px 12px;
  border-radius: 4px;
  background: rgba(0, 0, 0, 0.8);
  color: #fff;
  font-size: 0.9rem;
  line-height: 1.2;
  cursor: grab;
  user-select: none;
  opacity: 0;
  transition: opacity 0.12s ease;
}
.row__grip::before {
  content: "\283F";
}
.row:hover .row__grip,
.row--dragging .row__grip {
  opacity: 1;
}
.row__grip:active {
  cursor: grabbing;
}
.row--dragging {
  opacity: 0.5;
}
/* The drop line, centred in the 16px gap between cards (.queue's gap). */
.row--drop-before::before,
.row--drop-after::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  height: 4px;
  border-radius: 2px;
  background: var(--accent);
  pointer-events: none;
}
.row--drop-before::before {
  top: -11px;
}
.row--drop-after::after {
  bottom: -11px;
}

/* ---- Non-embeddable cards (↗ YouTube) ---- */

/* The footer "↗ YouTube" link for videos that can't play in the app. Rendered as
   an <a> but sits in the row's action footer, so it inherits the same ghost
   sizing as Play (same padding/height => card height is unchanged) and, with no
   speed group beside it, grows across the whole non-Skip portion. A subtle
   YouTube-red tint marks it as an off-app affordance. */
.row__actions .btn--youtube {
  color: var(--primary);
}
.row__actions .btn--youtube:hover:not(:disabled) {
  background: color-mix(in srgb, var(--primary) 12%, transparent);
  color: var(--primary);
}

/* The thumbnail hover overlay for a non-embeddable card uses the ↗ glyph; tint
   the puck YouTube-red so it reads as "opens on YouTube" rather than in-app play. */
.row__play--external .row__play-icon {
  background: var(--primary);
  padding-left: 0; /* ↗ needs no optical nudge (that was for the ▶ glyph) */
}

/* ---- Handled (marked) cards ---- */

/* Grey ONLY the thumbnail and text — never the action buttons — and do NOT gate
   this on :hover/:focus-within. So the acted-on card greys the instant its state
   class is set (no lag), regardless of whether a button inside it still holds
   focus or the pointer. opacity/filter change no layout, so card height is
   identical in every state. */
.row--handled .row__thumb-btn {
  opacity: 0.5;
  filter: grayscale(0.7);
}
.row--handled .row__meta {
  opacity: 0.55;
}

/* Taken-action indicator: a solid NEUTRAL slate fill on the Skip button driven by
   the aria-pressed set (synchronously) by setCardState. Buttons are excluded from
   the greying above, so it stays clearly legible at rest on a handled card, and
   the slate reads distinctly against the greyed thumbnail/text (not red/green). */
.row__actions .btn--skip[aria-pressed='true'] {
  background: var(--skip);
  border-color: var(--skip);
  color: var(--primary-text);
}
.row__actions .btn--skip[aria-pressed='true']:hover:not(:disabled) {
  /* Keep the label light on hover; only nudge the fill a touch darker. */
  background: color-mix(in srgb, var(--skip) 88%, #000);
  color: var(--primary-text);
}

/* ---- Two-pane workspace (queue + player) ---- */

/* Full-height app view: the PAGE does not scroll — only the left queue pane
   scrolls, so the right player is always visible. Scoped to body.app-active so
   the onboarding panels still scroll normally. */
body.app-active {
  height: 100dvh;
  overflow: hidden;
  display: flex;
  flex-direction: column;
}
body.app-active .topbar {
  flex: 0 0 auto;
  /* Full width, but every child is centred text or a centred flex row, so the
     header shares one centre axis with the capped, centred main below it. */
  max-width: none;
}
/* CENTRED, and capped at exactly the width the two panes can fill: the video
   (height-limited — see --video-w), the gutter, the queue column, and this
   element's own side padding, which border-box puts inside the max-width. Below
   that cap the frame is the viewport and nothing here binds, so the layout is
   untouched; at and above it the player column stops outgrowing the video and
   the empty space that used to open between the video and the queue never
   appears. The 1080px floor is what keeps the player column clear of the bar's
   38rem stacking threshold: it is the stacked layout's breakpoint too, so in two
   panes the frame is never below it and the column never below 636px — 621px as
   the container query sees it, once the pane's own scrollbar is off — which clears
   608px, so the bar never stacks while two panes are on screen. Measure the
   SCROLLBAR-adjusted width when re-tuning this: 1060px looked sufficient on the
   column alone and was 7px short in practice. Change one and you must change the
   other, or the four-state layout comes back. There is
   deliberately no --maxw-wide upper bound: on a large TALL screen the video is
   width-limited and already fills its column, so clamping there would shrink the
   video to remove a gap that is not there. */
body.app-active main {
  flex: 1 1 auto;
  min-height: 0;
  max-width: max(
    1080px,
    calc(var(--video-w) + var(--pane-gap) + var(--queue-w) + var(--app-pad-x) * 2)
  );
  width: 100%;
  margin: 0 auto;
  display: flex;
  flex-direction: column;
  padding: 8px var(--app-pad-x) 12px;
}
body.app-active #app-main.app {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
}
/* An author `display` beats the UA [hidden] rule — so the rule above would keep
   #app-main on screen (focusable, in the a11y tree) behind the fatal-storage
   overlay when showFatalStorageError() hides it. Specificity must beat the
   0-1-2-1 above, hence the same selector plus [hidden]. */
body.app-active #app-main.app[hidden] {
  display: none;
}

.workspace {
  flex: 1 1 auto;
  min-height: 0;
  display: grid;
  /* The player takes ALL remaining width on the LEFT; fixed ~400px queue on
     the RIGHT. Source order is player-then-queue, so this is plain grid
     auto-placement and NOT a reorder: visual order and DOM order agree here
     and in the stacked layout below, which is why this stylesheet declares
     `order` NOWHERE and must not start: both declarations it used to carry
     put focus order at odds with visual order. */
  grid-template-columns: minmax(0, 1fr) var(--queue-w);
  gap: var(--pane-gap);
}
.workspace__queue {
  min-height: 0;
  /* A grid item's automatic minimum size is its CONTENT's min-content whenever
     its own overflow is visible — which is exactly what the stacked layout below
     makes both panes. Without this the pane cannot shrink below its widest card,
     and neither can the column holding it (a track's automatic minimum is
     min-content too, so even 1fr stops there), so one card wider than a narrow
     viewport pushes the whole PAGE sideways. Free above 1080px, where both panes
     are scroll containers and that minimum is already 0. */
  min-width: 0;
  overflow-y: auto; /* the queue pane's OWN scrollbar */
  /* NO padding here: the sticky header spans the full pane width edge-to-edge,
     and the list below carries the gutter that keeps card shadows from clipping. */
  padding: 0;
}
/* Cleanup control pinned at the top of the scrolling queue pane. Full-width,
   OPAQUE background (matches the pane/page via --bg, in light AND dark) so
   scrolled cards never show through/around it; stacks above the cards. STRETCH so
   Trim front / Hide skipped and the two .btn--icon jump buttons are one height
   (see the .btn height gotcha in CLAUDE.md); it wraps, so each LINE stretches to
   its own tallest and the header's height is unchanged either way. */
.queue-header {
  position: sticky;
  top: 0;
  z-index: 5;
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: stretch;
  padding: 8px 12px;
  background: var(--bg);
  border-bottom: 1px solid var(--border);
}
/* The header reads as TWO groups: the playing-jump and Trim front at the left,
   Hide skipped and the skipped-jump at the right. This marks the first item of
   the RIGHT group, and its auto margin absorbs all the free space between them.
   An auto margin moves no element PAST another, so DOM order stays the visual
   order and the tab order matches what is drawn — which is why the grouping is
   done this way and not with `order` or `row-reverse` (this stylesheet declares
   `order` nowhere; see .workspace). Sizing and the disabled look come from the
   base .btn / .btn--icon. stash.html has only the left group, so it carries no
   split and its two buttons sit together at the left. */
.queue-header__split {
  margin-left: auto;
}
.workspace__player {
  min-height: 0;
  min-width: 0; /* the same content-based grid minimum as .workspace__queue */
  overflow-y: auto; /* if player + info exceed the pane, scroll here — never clip */
  /* THE SCROLLPORT IS 6px BIGGER THAN THE CONTENT ON EVERY SIDE, AND THE
     CONTENT DOES NOT MOVE: the padding pushes it in, the equal negative margin
     pulls the box back out, and only the CLIP EDGE moves — the two must cancel.
     overflow-y:auto makes this a scroll container, which clips both axes at the
     padding box, and #player-title / #player-meta sit flush against the content
     edge, so their rings — drawn outside them, like every ring here — were
     sliced off at x < 0. Not fixable from the link's side, nor by
     overflow-clip-margin: beside overflow-y:auto, overflow-x `clip` computes to
     `hidden` and the margin does nothing.
     The band must cover the WIDEST reach in here — a .btn's 4px, 2px outset over
     a 2px outline — and holds 2px over it. Three of the four edges are live:
     #player-title / #player-meta sit flush at the left, and .player__controls
     reaches the right whenever .player is 100% of the pane (a tall viewport) and
     the left as well once the @container query stacks the bar and the control
     row stretches. Nothing checks the bound — spend that 2px on a wider offset,
     a wider outline, or a new control nearer an edge and the ring clips
     silently; check it in a browser.
     On the scrolling axis the band is headroom only at the ends of the range:
     content hand-scrolled to an edge still loses a ring segment, as in any
     scroll container, and scroll-padding does not help.
     THE BAND GOES WHERE THE SCROLLPORT GOES — the stacked layout below drops
     both together, the height lock being released there. */
  padding: 6px;
  margin: -6px;
  /* Exists FOR the ring below: an outline traces its element's border-radius,
     so a square pane could only ever grow a square ring. Free when unfocused —
     the pane paints no background and no border of its own. Side effect: a
     scroll container's rounded corner clips the top of its own scrollbar. */
  border-radius: var(--radius);
}
/* The pane is a tab stop (tabindex="0" in the HTML), so this ring appears for
   three gestures: a Tab out of the queue, the '/' pane toggle, and the handoff
   off a now-playing control that is about to vanish (showPlayerEmpty). It is
   :focus-visible, so those draw a ring for a keyboard user; a CLICK on the pane
   background focuses it silently, and the general pointer-focus rule near the
   top of this file is what holds that answer past the next keydown.
   The offset must stay NON-NEGATIVE, keeping the ring wholly OUTSIDE the border
   box: an element paints its outline BEFORE its descendants, so an inset ring is
   painted over by .player__frame (full-bleed, black, flush to the pane's top and
   left corner) and on the right runs through the scrollbar, painted inside that
   same box. 0 rather than 2px only because the box has since grown 6px on every
   side above 1080px (see the padding/margin pair above; below it there is no band
   and the ring hugs the pane's own edge); going negative to win those back
   puts the right-hand segment on the scrollbar again, which rides outward with
   the padding box. Nothing clips it — 20px of .workspace grid gap to the left,
   12px to the right and below, at every width. A reserved transparent border, or
   padding behind an inset outline, would each cost 2px of pane permanently. */
.workspace__player:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 0;
}
.player {
  display: flex;
  flex-direction: column;
  gap: 10px;
  /* FILLS the pane. The 16:9 height cap that used to live here now lives in the
     frame's max-width (see `body.app-active main`), which sizes the player
     column to the video — so filling the column IS tracking the video wherever
     that cap binds, and this rule is a no-op there. Where it does NOT bind (the
     cap's 1080px floor, i.e. a short viewport) the video grows to fill the column
     instead of leaving dead space beside it, pushing the info and controls below
     the fold; the pane scrolls, so they stay reachable. The frame, now-playing
     info and controls all live in this wrapper, so the info still aligns to the
     video's LEFT edge and the right-aligned controls to its RIGHT edge. */
  width: 100%;
  /* Query container: whether the player__bar can hold title + controls side by
     side depends on THIS wrapper's width (which tracks the video), NOT the
     viewport — so the stack-order switch below is driven by a @container query.
     It now fires in the STACKED layout only: the frame's 1080px floor holds the
     two-pane column above the 38rem threshold. Width is set explicitly above, so
     inline-size containment does not affect it. */
  container-type: inline-size;
  container-name: player;
}
.player__frame {
  /* Scrolls WITH the rest of .player: as the pane scrolls, the video rises out
     of view and the full description is revealed (frame + bar + description are
     one continuous scrolling column). Must stay `relative` (not `static`) so the
     absolutely-positioned iframe / empty overlay below stay pinned inside this box. */
  position: relative;
  width: 100%; /* fills .player, which is height-capped to keep 16:9 + fit the viewport */
  aspect-ratio: 16 / 9;
  background: #000;
  border-radius: var(--radius);
  overflow: hidden;
}
/* Focus is INSIDE the cross-origin frame, put there by the '\' key: the outer
   document cannot ring what it cannot see, and the frame's own body draws
   nothing, so page-chrome mirrors its note as this class (a class, never
   :focus-within — see the note's comment there). The .btn ring exactly, and
   that is the bound: the box is flush to the pane's top and left corner, so
   above 1080px the ring sits in the 6px band the pane reserves, which holds a
   4px reach (2px outset over 2px) with 2px over — widen either and the top and
   left segments clip. Stacked, nothing clips. */
.player__frame--focused {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
/* The YT iframe replaces #player-mount; it and the empty overlay fill the box. */
.player__frame iframe,
.player__frame > * {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
}
.player__empty {
  display: flex;
  /* Column so the "Start the queue" button sits under the message, both centred
     in the (16:9, height-capped) frame. Unchanged when the button is hidden. */
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 12px;
  padding: 12px;
  text-align: center;
  color: var(--text-dim);
  background: var(--surface-2);
}
/* An author display value overrides the UA [hidden] rule, so hide explicitly. */
.player__empty[hidden] {
  display: none;
}
.player__empty-text {
  margin: 0;
}
/* Title and controls side by side, then the channel/date row (#player-meta)
   across the FULL width beneath both — under the controls was dead space. All
   by auto-placement in SOURCE order: the meta row is last in the markup and
   spans both columns, so Tab runs title → controls → channel as the eye does.
   The title's track has a 0 minimum, so a long title wraps within it rather than
   pushing the controls off their row; the @container query near the bottom of
   this file is the one thing that stacks them. */
.player__bar {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: start; /* a multi-line title top-aligns with the controls */
  gap: 8px 10px;
}
/* An author display value overrides the UA [hidden] rule, so hide explicitly.
   Hidden whenever nothing is playing; .player's flex `gap` applies only BETWEEN
   visible items, so no empty box, border or gap is left behind. */
.player__bar[hidden] {
  display: none;
}
.player__title {
  margin: 0;
  min-width: 0;
  font-weight: 600;
  /* Wrap a long title onto MULTIPLE lines (never nowrap/ellipsis/clamp) so it
     can't blow out the side-by-side layout at medium widths — the full title
     stays visible and the container query handles the stack point. */
  white-space: normal;
  overflow-wrap: anywhere;
}
/* The now-playing meta row (avatar + channel + dates): a card's meta row bar one
   thing — the avatar FLOATS instead of leaving the flow, so the text wraps
   AROUND it: lines beside it are indented, and a long name or the second date
   returns to the left edge once past it, where a card's row is indented all the
   way down. The float is still inside .row__channel-link, so a click on it still
   follows the link; the ring is the one thing it costs (see below).
   One line still centres on the 36px avatar: the padding lowers the text by half
   the difference and the float's equal negative margin lifts the avatar back to
   the top (a float's border box may rise into its container's padding). As a
   flow root this row grows to hold the float, so one line is a 36px row, as
   before. 1.35rem is the line: the body's 1.5 × this 0.9rem, in rem because the
   placeholder avatar sets its own size. No `metarow` container here: the bar's
   dates wrap by another rule (see .player__date). */
.player__meta {
  --meta-lift: calc((36px - 1.35rem) / 2);
  display: flow-root;
  grid-column: 1 / -1;
  padding-top: var(--meta-lift);
  color: var(--text-dim);
  font-size: 0.9rem;
}
.player__meta .row__avatar {
  float: left;
  margin: calc(-1 * var(--meta-lift)) 6px 0 0; /* 6px: .row__sub's gap after it */
}
/* The link's own outline would trace the floated avatar too — Chrome counts a
   float among an inline's outline rects, where it skips a positioned box — so
   here the ring moves onto the NAME, all it ever hugs in a card: the same ring,
   fragmenting per line the same way. The :not() carries over the pointer rule
   (.is-pointer-focused, near the top), which can only reach the focused link. */
.player__meta .row__channel-link:focus-visible {
  outline: none;
}
.player__meta .row__channel-link:focus-visible:not(.is-pointer-focused) .row__channel {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
  border-radius: 4px;
}
/* STRETCH, so the row is one height: .btn--icon (❤ / ⏭) and .btn--speed size
   themselves differently, and a base .btn's height is font-dependent besides.
   .player__speeds sets no align-items of its own, so the stretch reaches the
   three speed buttons through it — do not give that group one. */
.player__controls {
  display: flex;
  align-items: stretch;
  /* WRAPS in a pane narrower than the 308px the five equal buttons and their gaps
     need — a viewport under ~345px, where ⏭ Skip pushed the whole page sideways.
     Nowhere else: the pane is 311px at a 350px viewport and never below 457px in
     the two-pane layout, so both bar layouts keep their single line. Chosen over
     relaxing the 3.5rem min-width below, which does fit one line at 320px but at
     36–55px a button, neither an even row nor a touch target, on exactly the
     screens that are touched. This is the controls' OWN line only — they still
     never leave the title's row; the @container query alone moves them. The
     stretch works per line, so each line is its own tallest control's height. */
  flex-wrap: wrap;
  gap: 8px;
}
/* The now-playing video's description, below the sticky frame + info bar. Scrolls
   with the player pane (.workspace__player is overflow-y:auto). Preserves the
   description's own newlines and wraps long unbroken tokens (URLs) rather than
   overflowing. */
.player__description {
  margin-top: 4px;
  padding: 12px 14px;
  border-top: 1px solid var(--border);
  white-space: pre-wrap; /* preserve description line breaks */
  overflow-wrap: anywhere; /* wrap long URLs / tokens instead of overflowing */
  line-height: 1.5;
  font-size: 0.9rem;
  color: var(--text-dim);
}
/* A display value overrides the UA [hidden] rule, so hide explicitly. */
.player__description[hidden] {
  display: none;
}
/* Clickable in-video timestamps and plain URLs inside the description. */
.player__desc-ts,
.player__desc-link {
  color: var(--accent);
  cursor: pointer;
  text-decoration: none;
}
.player__desc-ts:hover,
.player__desc-link:hover,
.player__desc-ts:focus-visible,
.player__desc-link:focus-visible {
  text-decoration: underline;
}
/* The now-playing video's comments (js/comments.js), under the description and
   inset like it. That inset is also what keeps every ring in here — the .btn's
   4px reach, a comment landing's 6px — well clear of the pane's clip edge. */
.player__comments {
  display: flex;
  flex-direction: column;
  align-items: flex-start; /* the two .btns keep their own width */
  gap: 16px;
  margin-top: 4px;
  padding: 12px 14px;
  border-top: 1px solid var(--border);
  font-size: 0.9rem;
}
.player__comments[hidden] {
  display: none;
}
/* The ladder's last rung when the button that loaded a page goes (tabindex -1):
   a landing, not a control, and as tall as every comment in it — the
   .queue:focus / .empty:focus reasoning. */
.player__comments:focus {
  outline: none;
}
.comments__list,
.comments__replies {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 16px;
}
.comments__list {
  align-self: stretch;
}
.comments__replies {
  gap: 12px;
}
/* A sub-thread (queue.js's nestReplies): one level deeper, its avatars under
   the root reply's text (24px avatar + the 10px gap). */
.comments__subreplies {
  list-style: none;
  margin: 12px 0 0;
  padding: 0 0 0 34px;
  display: flex;
  flex-direction: column;
  gap: 12px;
}
.comments__more-nested {
  margin: 8px 0 0 30px; /* 34px, less the text button's own -4px pull */
}
/* The disclosure's target: the root replies plus their "Show more replies". */
.comments__replies-panel {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 8px;
  margin-top: 10px;
}
.comments__replies-panel[hidden] {
  display: none;
}
.comments__replies-panel .comments__replies {
  align-self: stretch;
}
.comment {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  gap: 10px;
  align-items: start;
  border-radius: 6px;
}
/* A LANDING (focused only by script, when the button that loaded it went away),
   ringed so a keyboard user sees where focus went. 6px reach, inside the
   section's 14px inset. */
.comment:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 4px;
}
.comment .row__avatar {
  width: 32px;
  height: 32px;
  font-size: 0.95rem;
}
.comments__replies .row__avatar {
  width: 24px;
  height: 24px;
  font-size: 0.75rem;
}
.comment__body {
  min-width: 0;
}
.comment__head {
  margin: 0;
  color: var(--text-dim);
  font-size: 0.85rem;
}
.comment__author {
  font-weight: 600;
  color: var(--text);
  overflow-wrap: anywhere;
}
.comment__dot {
  opacity: 0.6;
}
.comment__text {
  margin: 2px 0 0;
  white-space: pre-wrap; /* the comment's own line breaks */
  overflow-wrap: anywhere; /* .row__title's rule: no token runs off the pane */
  line-height: 1.5;
}
/* Under the comment's text, past its avatar (32px + the 10px gap). */
.comments__thread-tail {
  padding-left: 42px;
  margin-top: 4px;
}
/* A text button, the description links' accent: a .btn per thread (the reply
   toggle, "Show more replies", a sub-thread's "Show N more") would be a column
   of boxes louder than the comments. The toggle's chevron is alt-text-free generated content, so its
   name stays "3 replies". */
.comments__text-btn {
  padding: 2px 4px;
  margin-left: -4px;
  border: 0;
  border-radius: 4px;
  background: none;
  color: var(--accent);
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.comments__toggle::before {
  content: '▸' / '';
  display: inline-block;
  width: 1em;
}
.comments__toggle[aria-expanded='true']::before {
  content: '▾' / '';
}
.comments__text-btn:hover {
  text-decoration: underline;
}
.comments__text-btn:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}
.comments__text-btn[aria-disabled='true'] {
  opacity: 0.5;
  cursor: not-allowed;
}
.comments__status {
  margin: 0;
  color: var(--text-dim);
  border-radius: 4px;
}
.comments__status:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
/* A phone-width player: the two reply indents took ~40% of the column (124px
   of 307 at a 360px viewport), so they shrink to a step that still reads as
   nesting and stop lining avatars up under the text above. LAST in the block,
   since it overrides rules of equal specificity above it. */
@container player (max-width: 30rem) {
  .comments__thread-tail {
    padding-left: 20px;
  }
  .comments__subreplies {
    padding-left: 16px;
  }
  .comments__more-nested {
    margin-left: 12px;
  }
}
.player__speeds {
  display: flex;
  /* WRAPS, so this group is not the one thing .player__controls' own wrap cannot
     rescue: three buttons at the 3.5rem floor below are 180px of min-content, and
     a 190px viewport leaves the bar 175px — the group then overflowed a line it
     already had to itself. Wrapping drops its min-content to one button, and
     changes nothing above that, the group's max-content being what decides where
     the controls row breaks. Still NO align-items of its own (see
     .player__controls): the default stretch is what sizes the buttons, per line. */
  flex-wrap: wrap;
  gap: 6px;
}
/* Uniform width across ALL player control buttons (like / skip / 1× 1.5× 2×) so
   they line up as an even row. min-width is sized to the widest label ("1.5×");
   the base .btn already centres its content, so height/styling are unchanged.
   It is also the floor that makes the row WRAP rather than shrink on a phone —
   deliberately, since the shrunk widths are neither even nor touchable. */
.player__controls .btn {
  min-width: 3.5rem;
}
.btn--speed {
  padding: 6px 12px;
}
.btn--speed.is-active {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--primary-text);
}
.btn--speed.is-active:hover:not(:disabled) {
  background: color-mix(in srgb, var(--accent) 90%, #000);
  color: var(--primary-text);
}

/* Like button: outline at rest; when liked, the SAME deep-blue accent fill as
   the active speed button (shared --accent / --primary-text). */
.btn--like.is-active {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--primary-text);
}
.btn--like.is-active:hover:not(:disabled):not([aria-disabled='true']) {
  background: color-mix(in srgb, var(--accent) 90%, #000);
  color: var(--primary-text);
}

/* "Show all (N)" row at the bottom of the windowed queue. */
.queue-more {
  list-style: none;
  display: flex;
  justify-content: center;
  padding: 4px 0 8px;
}

/* Now-playing card: a FILL, never a ring — a ring reads as focus, and an accent
   ring here also LOST to .row:focus (0,2,0 over 0,1,0), so focusing the playing
   card erased its marker. Being a different PROPERTY, there is now nothing to
   out-specify: a focused playing card shows ring and tint at once.
   Deliberately the ONLY declaration — no border-color, so the perimeter stays
   neutral and .row:hover's lift and tinted border behave as on any other card.
   16% is a compromise across the themes, which carry the same mix very
   differently: --surface is white in light, so the mix moves a long way, while a
   light --accent into a near-black --surface barely shifts. One number for both,
   because this file's dark story is redefining TOKENS at :root and nothing else;
   it is also the one number to turn to make the tint louder or quieter. */
.row--playing {
  background: color-mix(in srgb, var(--accent) 16%, var(--surface));
}

/* Stack below this width: one column, the panes falling in SOURCE order —
   player above the queue, which is already what they are — and release the
   full-height lock so the whole page scrolls normally.
   1080px is not a round number, it is derived: the bar stacks at a 38rem (608px)
   player column, the column is the frame minus 444px (two 12px page paddings, the
   20px gutter, the 400px queue), and a scrolling player pane takes another ~15px.
   608 + 444 + 15 = 1067, so the two-pane layout starts above that with a little
   slack. Below it the document scrolls, which is also what stops the fixed top
   chrome from squeezing a player that cannot fit beside it. It MUST stay equal to
   the frame cap's floor above, and to narrowQuery in js/page-chrome.js. */
@media (max-width: 1080px) {
  body.app-active {
    height: auto;
    overflow: visible;
    display: block;
  }
  body.app-active main {
    display: block;
    max-width: var(--maxw-wide);
  }
  .workspace {
    grid-template-columns: 1fr;
  }
  .workspace__queue {
    overflow: visible;
    padding-right: 0;
  }
  /* The DOCUMENT scrolls here, but .queue-header is still sticky — against the
     viewport now — so a focused card still has to clear it. Taller than above
     because the header can wrap to two lines, at a point that moves with the
     digits in "Trim front (N)", so no breakpoint could be exact. Deliberately
     generous: clear the tallest header always. */
  .row {
    scroll-margin-top: 104px;
  }
  /* No scrollport here — the height lock is released above, so this pane never
     scrolls — and therefore no ring band either: the two go together, because
     overflow-y:auto would still CLIP at the padding box with nothing to scroll.
     Dropping the band also puts the pane inside the single column rather than
     6px outside it, which is what .workspace__queue already does here. */
  .workspace__player {
    overflow: visible;
    padding: 0;
    margin: 0;
  }
  .player {
    width: 100%; /* full-width player when stacked (the page scrolls here) */
    z-index: 5;
    background: var(--bg);
    padding-bottom: 6px;
  }
}

/* ---- Empty state ---- */

.empty {
  text-align: center;
  padding: 48px 16px;
  color: var(--text-dim);
}
.empty__title {
  font-size: 1.3rem;
  font-weight: 700;
  color: var(--text);
  margin: 0 0 6px;
}
.empty__sub {
  margin: 0;
}

/* ---- Responsive ---- */

@media (max-width: 620px) {
  /* The card grid already collapses to a single column at this width; only the
     toolbar needs to stack. */
  .queue {
    gap: 16px;
  }
  .toolbar {
    flex-direction: column;
    align-items: stretch;
  }
}

/* Player bar stacking, driven by the PLAYER PANE width (see container-type on
   .player), so it fires wherever the bar is too narrow to hold title + controls on
   one line. In practice that is the STACKED layout alone: the frame's 1080px
   floor keeps the two-pane column at 636px or more, clear of this threshold even
   once the pane's own scrollbar takes 15px off it. That is deliberate — two
   panes with a stacked bar was a fourth layout state nobody wanted.
   Threshold: the equal-width controls (5 buttons × min-width 3.5rem + the 8px/6px
   gaps ≈ 19.5rem) plus a comfortable title/channel area stop fitting side by side
   around the mid-30s rem. 38rem reliably catches a ~550–600px pane (the failing
   screenshot sits at ~34.4rem, right on the old 34rem edge) while keeping genuinely
   wide panes side by side (e.g. a ~620px / ~38.75rem laptop pane, and far wider
   desktop panes). Erring toward stacking is the desired direction. Above it, the
   wide title-left / controls-right layout and the equal-width buttons are
   unchanged.
   The stacked bar keeps SOURCE order — title, then the controls, then
   channel/dates — one column of the same grid, so the visual order is the tab
   order here too. Never reorder it (`order`, explicit grid rows): that puts
   focus order and visual order at odds for every keyboard user. */
@container player (max-width: 38rem) {
  .player__bar {
    grid-template-columns: minmax(0, 1fr);
    align-items: stretch;
  }
}

/* ---- Channels page (channels.html) ---- */

/* A single narrow column: the list reads best at prose width. */
.channels-main {
  max-width: var(--maxw);
}
.channels {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
}
/* An author `display` beats the UA [hidden] rule, so anything hideable needs its
   own [hidden] { display: none } — the same rule .queue carries. Without it the
   empty list keeps its padding box and stays in the accessibility tree behind
   the "no channels" state. */
.channels[hidden] {
  display: none;
}
/* flex-wrap lets the controls drop under the title on very narrow widths, so
   the stacked layout just works with no dedicated breakpoint. */
.chan {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 8px 14px;
  box-shadow: var(--shadow);
  /* NO scroll-margin-top, unlike .row — nothing on this page overlays a row. The
     topbar here is `position: static` and scrolls away, so a row brought to the
     very top edge is fully visible; .row needs its clearance because the queue's
     .queue-header is sticky and would cover a card landed there. A margin would
     now only push an upward landing further down the viewport than the browser
     asked for (measured 66px at 1280px), which is why it went when the page keys
     stopped forcing a top-edge landing. */
}
/* Focus / selection — .row:focus-visible's rule and its reasoning, on this page's
   row; see there for why it is a box-shadow ring over a transparent outline.
   The SPLIT is what differs, so only this is stated here. A .chan is not a
   control you activate: it is the cursor position for ↑/↓, x and 1/5/2 (see
   initListWalk in page-chrome.js), and :focus-visible alone does not mark it.
   Chrome propagates keyboard modality into a programmatic focus(), so the pane
   cycle's landing already rings through :focus-visible with no help. The case
   that needs the class is a MOUSE press on the row's own body — :focus-visible
   is false there, and without .chan--pointed the cursor those keys act on would
   be invisible after the one pointer gesture that IS a placement. Not
   :focus-within either — clicking a speed button or Ignore focuses that control
   and leaves the row unringed.
   Order is only readability here, unlike the card's: there is no .chan:hover for
   an identically-weighted rule to have to sit after. */
.chan:focus-visible,
.chan.chan--pointed:focus {
  outline: 2px solid transparent;
  outline-offset: 2px;
  box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--focus), var(--shadow);
}
/* .row__sub's job on this page: the flex item that holds the channel's identity,
   the positioning context its avatar is pinned to, and the reserved space the
   avatar sits in. min-height is the avatar's own — nothing else sets it once the
   avatar is out of flow, and without it a one-line name would collapse the row.
   padding-left is the 36px it vacated plus the 10px gap this row has always had
   between avatar and name (the card reserves 42px because its gap is 6px). */
.chan__identity {
  position: relative;
  display: flex;
  align-items: center;
  flex: 1 1 200px;
  min-width: 0;
  min-height: 36px;
  padding-left: 46px;
}
/* .row__subtext's job: ONE block of ordinary inline content, so the link inside
   stays inline and its ring can fragment per line. A flex container blockifies
   its children, so the link cannot be the flex item itself. */
.chan__identity-text {
  flex: 1 1 auto;
  min-width: 0;
}
/* The anchor holds the avatar AND the name, so clicking either navigates — but
   only the NAME is in flow inside it, so the link is exactly as wide as its text
   and the empty space beside a short name belongs to the row. Plain inline, with
   no box of its own, which is what lets a long name wrap inside it.
   Same idiom as .row__title and .row__channel-link: hover turns the name accent,
   focus draws a ring, nothing underlines. */
.chan__link {
  color: inherit;
  text-decoration: none;
}
.chan__link:hover .chan__title {
  color: var(--accent);
}
/* .row__channel-link's ring exactly, now that this box is the NAME alone rather
   than the avatar and the name together: the text links' 1px offset at a 4px
   radius. Still not a 999px pill — .chan__title wraps, and a pill would make end
   caps of half the box's height, curving back through the text. A wrapped name
   rings per line fragment, as a wrapped card title does. */
.chan__link:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
  border-radius: 4px;
}
.chan__title {
  font-weight: 600;
  overflow-wrap: anywhere;
}
/* Ignored channels grey their identity — never the controls — like handled cards.
   On .chan__identity rather than the link: `filter` on a non-none value makes an
   element the containing block for its absolutely-positioned descendants, so
   greying the link would re-anchor the avatar to the link's inline box and drop
   it on top of the name. The identity is the intended containing block already. */
.chan--ignored .chan__identity {
  opacity: 0.5;
  filter: grayscale(0.7);
}
/* STRETCH, so the 1× / 1.5× / 2× group and Ignore are one height: they size
   themselves differently, and a base .btn's height is font-dependent besides.
   .chan__speeds is a container, so it has to pass the stretch on. */
.chan__controls {
  display: flex;
  align-items: stretch;
  /* WRAPS and SHRINKS, as .row__actions does on a card: the group and Ignore want
     202px the row cannot always give them (115px at a 190px viewport), and
     unshrinkable they took the row, and the page's horizontal scroll, with them.
     .chan already wraps them onto their own line; this is the second break, after
     that line is itself too narrow. Free above ~340px, where the 202px fits. The
     stretch then works per LINE, so Ignore on a line of its own keeps its own
     height rather than the group's. */
  flex-wrap: wrap;
  gap: 8px;
  flex: 0 1 auto;
  margin-left: auto;
}
/* Same ghost treatment as a card's action footer, so the 1× / 1.5× / 2× group
   and Ignore read like the card buttons. */
.chan__controls .btn {
  border-color: transparent;
  background: transparent;
  color: var(--text-dim);
}
.chan__controls .btn:hover:not(:disabled) {
  background: var(--surface-2);
  color: var(--text);
}
.chan__speeds {
  display: flex;
  /* .row__speeds' rule, for .row__speeds' reason: three fixed 36px buttons are an
     unshrinkable 119px, and the row has only 75px of it at a 150px viewport. */
  flex-wrap: wrap;
  flex: 0 1 auto;
  /* Stretched by the row, and stretching in turn, so the three buttons are the
     row's height — per LINE once it wraps. The divider is this box's border, so
     it spans that height. */
  align-items: stretch;
  gap: 2px;
  padding-right: 6px;
  margin-right: 2px;
  border-right: 1px solid var(--border);
}
/* Active speed: the same deep-blue accent as the card speed buttons. */
.chan__controls .btn--cardspeed.is-active {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--primary-text);
}
.chan__controls .btn--cardspeed.is-active:hover:not(:disabled) {
  background: color-mix(in srgb, var(--accent) 90%, #000);
  color: var(--primary-text);
}
/* Active Ignore mirrors the Skip button's pressed slate fill. */
.chan__controls .chan__ignore[aria-pressed='true'] {
  background: var(--skip);
  border-color: var(--skip);
  color: var(--primary-text);
}
.chan__controls .chan__ignore[aria-pressed='true']:hover:not(:disabled) {
  background: color-mix(in srgb, var(--skip) 88%, #000);
  color: var(--primary-text);
}

/* ---- Stash page (stash.html) ---- */

/* The "add a video by link" block. It needs its OWN max-width: body.app-active
   replaces main's with the two-pane frame cap, which is far wider than prose,
   and without one the input would stretch the full width of that frame. Prose
   width is the same measure the onboarding panels use. */
.stash-add-block {
  max-width: var(--maxw);
  margin: 0 0 12px;
}
/* The form is a tab stop (tabindex="0" in the HTML) because the [ / ] pane cycle
   lands on it rather than on the field inside it, so it needs the house ring.
   :focus-visible, so the cycle and Tab draw one while a pointer press landing on
   the form focuses it silently. The radius shapes the outline ALONE — the form
   paints no background and no border — and is var(--radius), not the meta links'
   4px: this box is the label plus the whole input row, not a run of text.
   Nothing clips the 3px reach: .stash-add-block sets only max-width, every
   ancestor up to <body> is overflow: visible, and the nearest edge is the 12px
   side padding on `body.app-active main`. */
.stash-add:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
  border-radius: var(--radius);
}
.stash-add__label {
  display: block;
  font-weight: 600;
  margin-bottom: 6px;
  font-size: 0.9rem;
}
/* flex-wrap is the whole narrow-width story: the Add button drops under the
   input on its own, and the @media below then widens it to a real touch target.
   STRETCH because a .btn and a .field__input size from unrelated recipes (see the
   .btn height gotcha in CLAUDE.md) — the button was 5px shorter than the field
   beside it. It wraps, so once the button is on its OWN line it stretches to
   nothing but itself and keeps its natural height. */
.stash-add__row {
  display: flex;
  flex-wrap: wrap;
  align-items: stretch;
  gap: 8px;
}
/* Reuses .field__input for colours/radius/focus ring; ONLY the sizing is
   overridden, since .field__input is `width: 100%` (right for a stacked panel
   field, wrong for a flex row). min-width: 0 lets it actually shrink. */
.stash-add__input {
  flex: 1 1 320px;
  width: auto;
  min-width: 0;
}
.stash-add__btn {
  flex: 0 0 auto;
}
@media (max-width: 620px) {
  /* Once the button has wrapped onto its own line, let it span the row — a
     full-width tap target instead of a stranded pill. */
  .stash-add__btn {
    flex: 1 0 100%;
  }
}

@media (prefers-reduced-motion: reduce) {
  * {
    transition: none !important;
  }
}
