/* Min Radio — Scandinavian minimal, iPhone-13-first */

:root {
  --bg: #f7f6f2;
  --surface: #ffffff;
  --text: #1c1c1e;
  --text-secondary: #6e6e73;
  --accent: #00504e;
  --accent-soft: #e3efee;
  --amber: #ffd166;
  --danger: #b3261e;
  --border: #e6e4dd;
  --radius: 16px;
  --radius-sm: 12px;
  --safe-top: env(safe-area-inset-top, 0px);
  --safe-bottom: env(safe-area-inset-bottom, 0px);
  --shadow: 0 1px 3px rgba(28, 28, 30, 0.06), 0 4px 14px rgba(28, 28, 30, 0.05);

  /* ---- WS75: ONE icon size, shared by the home icons AND the EXTENDED
   * player's album cover ----
   * OWNER, 2026-10-08: "the album covers could get exactly the same pixel width
   * and height as the channel and podcast icons".
   * OWNER CORRECTION, 2026-10-09: it is the album cover in the EXTENDED player
   * that must be icon-sized; the NORMAL player's cover stays 44px "as it was".
   * (WS75 first applied it to the normal player's cover -- the opposite of the
   * instruction -- and that is corrected here.)
   *
   * The only way they cannot drift apart is for BOTH to read the SAME value, so
   * the arithmetic lives here once. A literal in either place would satisfy a
   * screenshot today and silently disagree at the next width or the next tweak.
   *
   * Derivation: the icon row is four squares plus three 14px gaps inside the
   * content's inline padding. `.content` is this app's only scroller, and at
   * <=340px its inline padding drops to 12px (see the media block at the
   * bottom), so the constant differs either side of that breakpoint -- which is
   * why there is a matching override below rather than one formula claiming to
   * cover both.
   *
   * MEASURED at 390px (the owner's iPhone 13): 77.03px, and the extended
   * player's cover equals it to 0.00px at 320/340/360/375/390/430/768px. */
  --icon: calc((min(100vw, 480px) - 82px) / 4);

  /* ITEM 1, FOURTH PASS (correction). Where the settings sheet's band and the
   * Info page's band both START.
   *
   * OWNER, verbatim: "the position of both bands should be in the same position
   * and move up so they cover the word Kanaler for a sleak look".
   *
   * "Cover the word Kanaler" is the position. Measured on the main screen, the
   * "Kanaler" section title sits at y=76, and it is derived like this:
   *
   *   .app      padding-top : var(--safe-top) + 8px
   *   .topbar   height      : 60px   (padding 10px 20px 6px)
   *   .content  padding-top : 8px
   *   --------------------------------------------------------
   *   band y                  : var(--safe-top) + 76px
   *
   * WHY THIS IS A VARIABLE AND NOT A LITERAL 76px: the chain above includes
   * --safe-top, which is 0 in a desktop browser and ~47px on the owner's iPhone.
   * A literal would align the two bands on my test rig and leave them 47px apart
   * on the device. Deriving it means both surfaces follow the main screen if
   * the header ever changes height.
   *
   * WHAT THIS DOES NOT PROVE: it is derived from the computed heights above and
   * confirmed by measurement on a 768px desktop viewport at --safe-top: 0. It
   * has NOT been measured on the iPhone, where --safe-top is non-zero. The owner
   * must confirm the band still covers "Kanaler" there.
   */
  --band-y: calc(var(--safe-top) + 76px);
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #16161a;
    --surface: #232329;
    --text: #f2f2f4;
    --text-secondary: #a5a5ab;
    --accent: #7fc8c4;
    --accent-soft: #2a3d3c;
    --border: #33333a;
    --shadow: 0 1px 3px rgba(0, 0, 0, 0.3), 0 4px 14px rgba(0, 0, 0, 0.2);
  }
}

* { box-sizing: border-box; margin: 0; padding: 0; }

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

body {
  font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, sans-serif;
  background: var(--bg);
  color: var(--text);
  line-height: 1.45;
  -webkit-font-smoothing: antialiased;
  overscroll-behavior-y: none;
}

.app {
  min-height: 100dvh;
  padding-top: calc(var(--safe-top) + 8px);
  padding-bottom: calc(var(--safe-bottom) + 100px); /* room for mini player */
  max-width: 480px;
  margin: 0 auto;
}

/* ---------- top bar ---------- */
.topbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 20px 6px;
}

/* WS45: the weather lives in a wrapper that HOLDS the brand, so `.topbar`
   keeps exactly two children. WS11 added a third child to this bar and the
   cog jumped to the middle -- an owner-reported regression. `.topbar` is
   `justify-content: space-between`, so anything placed between its two ends
   gets centred. Grouping inside `.topbar-left` cannot do that. */
.topbar-left {
  display: flex;
  align-items: center;
  gap: 10px;
  min-width: 0;   /* let the brand ellipsis instead of pushing the cog off-screen */
}

.brand {
  font-size: 22px;
  font-weight: 700;
  letter-spacing: -0.02em;
  white-space: nowrap;
}

.weather {
  display: flex;
  align-items: center;
  gap: 5px;
  min-width: 0;
  color: var(--text-secondary);
  font-size: 13px;
  white-space: nowrap;
  /* WS82: the chip is a real <button> now. A button does NOT inherit
     font-family from its ancestors -- it uses the UA default -- so without
     this line the header text changes font and size the moment the element
     becomes a button. Verified on the rendered DOM, not in source. */
  font-family: inherit;
  /* WS46: tapping the chip forces a refresh, so it must LOOK tappable and
     must have a real touch target. The transparent background is what stopped
     the WS43b red-row overlay from bleeding into this element. */
  background: transparent;
  border: none;
  padding: 4px 6px;
  margin: 0 -6px;
  border-radius: 8px;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: background-color 0.15s ease;
}
.weather:hover { background: var(--accent-soft); }
.weather:active { background: var(--accent-soft); }
.weather:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
/* The no-reading state (WS46). Dimmed so it reads as "not known yet" rather
   than as a real observation, but still a visible, tappable target. */
.weather-empty { opacity: 0.65; }
.weather-empty .weather-place { font-style: italic; }
.weather-icon { display: flex; align-items: center; color: var(--accent); }
.weather-icon svg { width: 17px; height: 17px; }
.weather-temp {
  font-weight: 600;
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
}
/* The place can be a long name ("Inom Vallgraven"). Truncate rather than
   pushing the cog off the right edge on a narrow phone. */
.weather-place {
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 11ch;
}

.edit-btn {
  width: 44px;
  height: 44px;
  border-radius: 50%;
  color: var(--accent);
  /* OWNER, 2026-10-06: circle background removed, matching .info-btn. */
  background: none;   /* was var(--accent-soft) */
  border: none;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  flex: none;
}
.edit-btn svg { width: 22px; height: 22px; }
.edit-btn:focus-visible,
button:focus-visible,
a:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* ---------- content ---------- */
.content { padding: 8px 20px 0; }

.section { margin-bottom: 26px; }

.section-title {
  font-size: 13px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--text-secondary);
  margin-bottom: 12px;
}

.grid-2 {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
}

/* ---------- icon sections with continuous scroll (home screen) ----------
 * Icons are sized so EXACTLY 4 fill the visible width on any screen;
 * additional icons sit fully off-screen and are revealed by scrolling. */
.icon-scroller {
  display: flex;
  gap: 14px;
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior-x: contain;
  scroll-snap-type: x proximity;
  padding-bottom: 4px;
  scrollbar-width: none; /* Firefox */
}
.icon-scroller::-webkit-scrollbar { display: none; }

/* 3 gaps (14px × 3) are subtracted so 4 icons + gaps = exactly the visible width.
   WS75: the value is now the shared --icon (see :root), which the player cover
   also reads, so the two are the same square by construction. */
.icon-scroller .stream-icon,
.icon-scroller .icon-empty {
  scroll-snap-align: start;
  flex: 0 0 var(--icon);
  width: auto;
  aspect-ratio: 1;
}

/* WS75: the <=340px viewport halves `.content`'s inline padding (20 -> 12px, see
   the media block at the bottom of this file), so the icon arithmetic changes
   with it. This override must move IN STEP with that padding rule, or the cover
   and the icons disagree below 340px. */
@media (max-width: 340px) {
  :root { --icon: calc((100vw - 66px) / 4); }
}

/* ---------- selected favorites (settings view, with custom sorting) ---------- */
.selected-groups { display: flex; flex-direction: column; gap: 18px; }

.selected-group-title {
  font-size: 13px;
  font-weight: 600;
  color: var(--text-secondary);
  margin-bottom: 6px;
}

.selected-item {
  display: flex;
  align-items: center;
  /* NO background here. The visible surface moved onto .selected-item-face so
     it can paint ABOVE the delete panel; leaving a background on the row would
     sit under the panel and the row would read red at rest. */
  background: transparent;
  border-radius: var(--radius-sm);
  padding: 8px 10px;
  margin-bottom: 6px;
  cursor: grab;
}
.selected-item.dragging { opacity: 0.45; outline: 1.5px dashed var(--accent); }

/* Swipe-to-remove (2026-10-02), in the iOS Mail / WhatsApp idiom: the row's
   CONTENT slides left to uncover a coloured delete panel behind it. The
   podcast stays readable the whole time -- the owner was explicit that a
   covered row makes the favourites list unreadable, which is the one thing
   this list must never be.

   THE PAINTING-ORDER TRAP, which is the whole reason this needs care. The row
   is `position: relative` and the panel is `position: absolute`, while the
   row's own children (.selected-name, .selected-logo, .selected-pos) are
   `position: static`. In CSS, POSITIONED elements paint above all
   non-positioned siblings -- so the panel covered the entire row even though
   it was written first and is semantically "behind". Giving every content
   child `position: relative` puts them in the positioned layer too, and
   source order then decides: panel first, content after, content on top. */
.selected-item { position: relative; touch-action: pan-y; }

.swipe-reveal {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  padding-right: 20px;
  border-radius: var(--radius-sm);
  background: var(--danger, #c0392b);
  color: #fff;
  pointer-events: none;
  /* No transition here: the PANEL does not move. The row slides across it,
     which is what produces the "wipe away" feel. */
}
.swipe-reveal-icon { display: flex; align-items: center; }
.swipe-reveal-icon svg { width: 22px; height: 22px; }

/* The content layer. `position: relative` with no offsets moves nothing; it
   only lifts these above the absolutely-positioned panel. Without this the
   panel paints over the name and logo (observed in a screenshot, not guessed). */
/* THE FACE: the row's visible surface, on its own positioned layer ABOVE the
   delete panel. This is the second half of the painting-order fix.

   A parent's own background is painted BEFORE any positioned child, so an
   absolutely-positioned panel is always above it -- giving the children
   `position: relative` makes the TEXT readable but leaves the row's white
   surface underneath, so the panel still shows through and every row reads as
   a red block at rest. Moving the surface colour onto this inner layer is what
   actually puts white above red. It slides as one piece with its contents. */
.selected-item-face {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  gap: 10px;
  background: var(--surface);
  border-radius: var(--radius-sm);
  /* Inherit the row's box so the padding lives on the face, not the row. */
  padding: 8px 10px;
  margin: -8px -10px;
  min-height: 100%;
  /* THE ROW IS display:flex and the face is its ONLY in-flow child (the
     delete panel is absolute), so without flex-grow the face sizes to its
     CONTENT and leaves the rest of the row bare -- which is exactly the red
     strip that showed down the right-hand side of every row in the harness
     screenshot. Growing to fill is what makes white cover red at rest.
     min-width:0 lets .selected-name's own flex:1 ellipsis work. */
  flex: 1;
  min-width: 0;
}

.selected-item.swiping { cursor: grabbing; }
.selected-item.swiping .selected-grip { opacity: 0.4; }

/* A long-press drag must still be possible, so the row only claims the
   horizontal axis. pan-y leaves vertical panning to the scroller, which is
   what Safari needs in order to stop fighting the page for the gesture. */

.selected-grip {
  color: var(--text-secondary);
  flex: none;
  display: flex;
  align-items: center;
}
.selected-grip svg { width: 16px; height: 16px; }

.selected-pos {
  width: 20px;
  font-size: 12px;
  font-weight: 700;
  color: var(--text-secondary);
  text-align: center;
  flex: none;
  font-variant-numeric: tabular-nums;
}

.selected-logo {
  width: 36px;
  height: 36px;
  border-radius: 8px;
  object-fit: cover;
  flex: none;
  background: var(--accent-soft);
}
.selected-letter {
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--accent);
  font-weight: 700;
}

.selected-name {
  flex: 1;
  min-width: 0;
  font-size: 14px;
  font-weight: 600;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.selected-controls { display: flex; gap: 4px; flex: none; }

.selected-btn {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  border: none;
  background: transparent;
  color: var(--accent);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}
.selected-btn svg { width: 22px; height: 22px; }
.selected-btn:disabled { opacity: 0.25; cursor: default; }

/* The explicit remove control. Uses --danger (already defined at the top of
   this file) rather than introducing a second red. */
.selected-btn-remove { color: var(--danger); }
.selected-btn-remove:active { background: rgba(179, 38, 30, 0.12); }

.selected-empty {
  font-size: 13.5px;
  color: var(--text-secondary);
  padding: 4px 2px 8px;
}

/* ---------- icon rows (channels & podcasts) ---------- */
.icon-row {
  display: flex;
  gap: 14px;
}

.stream-icon {
  border: none;
  border-radius: 18px;
  overflow: hidden;
  padding: 0;
  background: var(--surface);
  box-shadow: var(--shadow);
  cursor: pointer;
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  /* Fas 5 long-press: iOS shows the native "Save to Photos / Copy" menu on
     long-press of images unless the callout is disabled. The long-press
     belongs to OUR context cards (user report 2026-09-23: iOS menu hijacked
     the gesture on all but one icon). */
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
  -webkit-user-drag: none;
}
.stream-icon img { width: 100%; height: 100%; object-fit: cover; display: block; }

/* ---- WS20: the WS19 `.icon-hint` caption is REMOVED ----
   The owner: "i never ever complained about the pod icon to start the latest
   podcast. that is by design and was decided in the start of the project."

   The caption was my unrequested decoration, added in an attempt to make the
   long-press episode list discoverable. The rule is deleted rather than left
   unused: a dead selector invites a future reader to believe the caption
   exists. The tap behaviour itself was never modified. */

.stream-icon img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  -webkit-touch-callout: none; /* img is the long-press target — must not call out */
  -webkit-user-drag: none;
  pointer-events: none; /* the button owns the gesture, not the img */
}

.icon-letter {
  font-size: 24px;
  font-weight: 700;
  color: var(--accent);
}

.icon-empty {
  border-radius: 18px;
  border: 1.5px dashed var(--border);
}

/* playing state: teal ring around the icon */
.stream-icon.playing::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 18px;
  border: 3px solid var(--accent);
  pointer-events: none;
}

/* subtle stop glyph overlay while playing */
.stream-icon.playing .stop-glyph,
.stream-icon.playing::before {
  content: '';
  position: absolute;
  width: 18px;
  height: 18px;
  border-radius: 4px;
  background: rgba(0, 0, 0, 0.55);
  z-index: 2;
}

/* ---------- news scroller (4 visible, scroll to more) ---------- */
.news-scroller {
  /* The +6px is the padding added below (3px top + 3px bottom). Without it the
   * added padding would eat 6px of the scroll viewport and the list would show
   * slightly LESS than four rows -- a quiet regression in a box whose whole
   * purpose is "4 rows + gaps", and one that no unit test would notice because
   * the row COUNT property is untouched. */
  max-height: calc(4 * 106px + 3 * 10px + 6px); /* 4 rows + gaps + padding */
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior: contain;
  padding-right: 2px;
  /* ---- WS67: stop clipping the focused/playing ring ----
   *
   * OWNER, with a screenshot: "a small adjustment of the news titles positions
   * so that the border lines left and the top position are shown. see attach the
   * chromium example that is the same on iphone pwa using safari."
   *
   * MEASURED (driven DOM, 390px, `.news-item.playing` forced): the first item
   * sits at gapLeft 0 and gapTop 0 INSIDE this element, and `overflow-y: auto`
   * establishes a scroll container that clips anything drawn outside its own
   * padding box. `.news-item.playing` draws `box-shadow: 0 0 0 2px var(--accent)`
   * -- an OUTSET shadow -- so the 2px on the left and the 2px on the top were
   * being cut off. Exactly the two edges the owner reported, and no others.
   *
   * The right edge already had 2px (`padding-right`, pre-existing) and the
   * bottom was never reported, which is the tell that this is clipping rather
   * than a misplaced border: a symmetric gap on all four sides would have been a
   * different defect.
   *
   * The fix is padding, not a repositioned shadow: an inset border would change
   * the ring's appearance and lose the "outside the card" reading, and a
   * negative margin would move the layout. 3px clears the 2px ring on both
   * edges; the 1px spare means a fractional device pixel ratio cannot clip it.
   *
   * `scroll-padding-top` is deliberately NOT set: this box is not the scroll
   * container for the page, so it would do nothing.
   */
  padding: 3px 2px 3px 3px;
}

/* ---------- Nyheter collapse/expand (user request 2026-09-23, CORRECTED) ----------
   The section header is a full-width toggle. DEFAULT = FULLY EXPANDED (all
   items visible, no peek — the peek behavior was explicitly rejected).
   Collapsed (during playback) = header only, zero item visibility. The
   player stays on top (fixed, higher z-index). */
/* WS70: the header row carries the fold toggle AND the autoplay toggle as
   siblings. The fold button takes flex: 1 so the chevron stays at the right
   edge and the loop icon sits immediately right of NYHETER. */
.news-header-row {
  display: flex;
  align-items: center;
  margin-bottom: 12px;
}
.news-toggle {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex: 1;
  min-width: 0;
  background: none;
  border: none;
  padding: 0;
  color: inherit;
  font: inherit;
  cursor: pointer;
  text-align: left;
}
.news-toggle .section-title { margin-bottom: 0; }
.news-toggle-chevron {
  width: 10px;
  height: 10px;
  border-right: 2px solid var(--text-secondary);
  border-bottom: 2px solid var(--text-secondary);
  transform: rotate(-135deg); /* pointing up = expanded (default) */
  transition: transform 0.2s ease;
  /* OWNER, 2026-10-07: "the chevron is too tight to the autoplay toggle" and
     "align the chevron ... with the horizontal position of the information
     icon". Measured at 390px: info cx 302.1; the chevron sat at cx 313.1.
     19px of margin moves its centre onto the info column exactly (driven in
     the browser, not inferred), while the autoplay glyph's right edge stays
     on the shared alignment line with the cog and flash. */
  margin-right: 19px;
  flex: none;
}
.news-section:not(.expanded) .news-toggle-chevron {
  transform: rotate(45deg); /* pointing down = collapsed */
}
/* Collapsed: hide the list entirely — header only, NO peek. */
.news-section:not(.expanded) .news-scroller,
.news-section:not(.expanded) .state-msg {
  display: none;
}
@media (prefers-reduced-motion: reduce) {
  .news-toggle-chevron { transition: none; }
}

/* WS70: the news autoplay toggle. Icon-only, styled after .fresh-btn (44px
   target, 22px glyph) so the two header controls read as the same family.
   The accent colour marks the ACTIVE (autoplay) mode; manual mode is the
   dim secondary text colour. No visible text anywhere. */
.news-autoplay-btn {
  width: 44px;
  height: 44px;
  flex: none;
  align-self: center;
  border-radius: 50%;
  border: none;
  background: none;
  color: var(--text-secondary);
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
}
.news-autoplay-btn.active { color: var(--accent); }
.news-autoplay-btn:active { opacity: 0.6; }
.news-autoplay-btn svg { display: block; width: 22px; height: 22px; }

.news-item {
  display: flex;
  gap: 12px;
  width: 100%;
  background: var(--surface);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  padding: 12px;
  margin-bottom: 10px;
  color: inherit;
  align-items: flex-start;
  border: none;
  font: inherit;
  text-align: left;
  cursor: pointer;
  min-height: 44px;
}

.news-item.playing {
  box-shadow: 0 0 0 2px var(--accent), var(--shadow);
}

.news-thumb {
  width: 64px;
  height: 64px;
  border-radius: var(--radius-sm);
  object-fit: cover;
  flex: none;
  background: var(--accent-soft);
}

.news-text { min-width: 0; }

.news-title {
  font-size: 14.5px;
  font-weight: 600;
  line-height: 1.3;
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.news-meta {
  font-size: 12px;
  color: var(--text-secondary);
  margin-top: 4px;
}

/* ---------- in-app article reader ----------
 *
 * WS58: the Info page NO LONGER USES ANY OF THIS. It is built from the
 * settings sheet's own element (`.sheet` + `.sheet-grab-zone` +
 * `.sheet-header`), so there is now ONE band definition, ONE header, and ONE
 * close control in this stylesheet instead of two that merely agreed on a
 * number. Everything below survives ONLY for two surfaces that were never part
 * of the complaint and legitimately keep a full-height, non-sheet reader:
 *
 *   - the in-app NEWS article reader (openArticle)
 *   - the Tests / diagnostics panel (openAbout)
 *
 * The reader band rules, its `::after` drag target, the `> *:not(...)` lift and
 * the duplicate `.reader { position: relative }` block are GONE. They existed
 * only for the Info page, and leaving a second band's rules behind is how the
 * two surfaces drifted apart in the first place — which is the defect WS58 was
 * opened to close. A grep for the class, on comment-stripped source, must
 * return nothing.
 *
 * The duplicate `.reader { }` block that used to sit further down was also the
 * same class of defect as the duplicate `.sheet` rule fixed earlier in this
 * log: the first declaration wins and the second reads as if it applied. It is
 * collapsed into the single declaration below.
 */
.reader-overlay {
  position: fixed;
  inset: 0;
  z-index: 50;
  background: var(--bg);
  display: flex;
  justify-content: center;
}

.reader {
  width: 100%;
  max-width: 480px;
  display: flex;
  flex-direction: column;
  height: 100dvh;
  will-change: transform; /* smooth swipe-to-close */
}

.reader-topbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 20px;
  border-bottom: 1px solid var(--border);
  flex: none;
  /* ITEM 1, second pass, retained for the two surfaces that still live here:
     without it the UA may claim the drag as a scroll and their swipe-to-close
     is unreliable on iOS. */
  touch-action: none;
}

.reader-brand {
  font-size: 15px;
  font-weight: 700;
  color: var(--text-secondary);
}

.reader-close {
  border: none;
  background: var(--border);
  color: var(--text);
  width: 32px;
  height: 32px;
  border-radius: 50%;
  font-size: 15px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
}

.reader-body {
  flex: 1;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  padding: 20px;
  padding-bottom: calc(var(--safe-bottom) + 32px);
}

.reader-image {
  width: 100%;
  border-radius: var(--radius);
  margin-bottom: 16px;
  background: var(--accent-soft);
}

.reader-title {
  font-size: 22px;
  font-weight: 800;
  line-height: 1.25;
  letter-spacing: -0.01em;
  margin-bottom: 8px;
}

.reader-meta {
  font-size: 12.5px;
  color: var(--text-secondary);
  margin-bottom: 20px;
}

.reader-para {
  font-size: 16px;
  line-height: 1.6;
  margin-bottom: 14px;
  color: var(--text);
}

.reader-source {
  display: inline-block;
  margin-top: 8px;
  font-size: 14px;
  font-weight: 600;
  color: var(--accent);
  text-decoration: none;
  min-height: 44px;
  line-height: 44px;
}

/* ---------- settings section (news count) ---------- */
.setting-section { margin-top: 4px; margin-bottom: 14px; }
.setting-section .section-title { margin-bottom: 8px; }

.setting-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 14px;
  border-radius: var(--radius-sm);
  border: 1.5px solid var(--border);
  background: var(--surface);
}

.setting-row .setting-range { flex: 1; }

.setting-minmax {
  font-size: 12px;
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
  flex: none;
}

.setting-value {
  font-size: 14px;
  font-weight: 700;
  color: var(--accent);
  min-width: 22px;
  text-align: center;
  font-variant-numeric: tabular-nums;
}

/* compact, nicer slider — value bubble rides on the thumb */
.setting-slider-wrap {
  position: relative;
  flex: 1;
  display: flex;
  align-items: center;
}

.setting-range {
  -webkit-appearance: none;
  appearance: none;
  width: 100%;
  height: 34px;
  background: transparent;
  margin: 0;
}
.setting-range::-webkit-slider-runnable-track {
  height: 4px;
  border-radius: 2px;
  background: var(--border);
}
.setting-range::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 34px;
  height: 34px;
  border-radius: 50%;
  background: var(--accent);
  margin-top: -15px;
  border: none;
}
.setting-range::-moz-range-track {
  height: 4px;
  border-radius: 2px;
  background: var(--border);
}
.setting-range::-moz-range-thumb {
  width: 34px;
  height: 34px;
  border-radius: 50%;
  background: var(--accent);
  border: none;
}

.thumb-bubble {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  width: 34px;
  height: 34px;
  border-radius: 50%;
  background: var(--accent);
  color: #fff;
  font-size: 12.5px;
  font-weight: 700;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
  font-variant-numeric: tabular-nums;
}

/* ---------- header action row (Info + Spara) — same look as tabs ---------- */
.sheet-actions {
  display: flex;
  gap: 8px;
  margin-bottom: 16px;
}

.sheet-action {
  flex: 1 1 0;
  min-width: 0;
  padding: 10px 0;
  min-height: 44px;
  border-radius: var(--radius-sm);
  border: none;
  background: var(--surface);
  color: var(--text-secondary);
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
}

.sheet-action-info { color: var(--text-secondary); }

.sheet-save {
  background: var(--accent);
  color: #fff;
}
.sheet-save:disabled { opacity: 0.4; }

/* ---------- in-app about overlay ---------- */
.about-title { font-size: 22px; font-weight: 800; margin-bottom: 4px; }
.about-version { font-size: 13.5px; color: var(--text-secondary); margin-bottom: 18px; }
.about-heading {
  font-size: 13px; text-transform: uppercase; letter-spacing: 0.06em;
  color: var(--text-secondary); margin: 20px 0 8px;
}
.about-card {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: 14px; padding: 12px 14px; margin-bottom: 8px;
}
.about-card b { display: block; font-size: 15px; margin-bottom: 2px; }
.about-card span { font-size: 14px; color: var(--text-secondary); }
.about-list { padding-left: 20px; font-size: 14px; }
.about-list li { margin-bottom: 6px; }
.about-list a { color: var(--accent); }
.about-para { font-size: 14px; color: var(--text-secondary); margin-bottom: 10px; }

/* ---- WS29: the visible timing readout, in the Info sheet. ----
   Deliberately plain and in the sheet's own visual language: this is a
   diagnostic for a non-technical owner, so legibility beats styling. The
   readout is the one element that must never be ambiguous, so it gets a
   monospace figure and a real min-height — a number that appears and
   disappears as the reading comes and goes is unreadable in a photograph. */
.about-diag { margin-top: 22px; padding-top: 4px; }
.about-diag .about-heading { margin-top: 0; }

.about-diag-switch {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  width: 100%;
  text-align: left;
  font: inherit;
  font-size: 14px;
  color: var(--text);
  cursor: pointer;
  min-height: 44px; /* iOS tap-target guidance */
}
.about-diag-switch .setting-minmax:last-child {
  font-variant-numeric: tabular-nums;
  color: var(--text-secondary);
}
/* The pressed state is shown by the word AND the attribute, never by colour
   alone — colour-only state is invisible to a colour-blind owner and
   unreadable in a screenshot. */
.about-diag-switch[aria-pressed='true'] { color: var(--accent); }
.about-diag-switch[aria-pressed='true'] .setting-minmax:last-child {
  color: var(--accent);
}

.about-diag-readout {
  font-size: 15px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  margin: 6px 0 0;
  min-height: 22px; /* no layout jump when the reading appears */
}
.about-diag-note {
  font-size: 13px;
  color: var(--text-secondary);
  margin: 4px 0 0;
  min-height: 18px;
}
/* Switched off: the controls are hidden, not removed, so the panel is present
   but inert. `.is-off` also gates the readout text, which stays empty. */
.about-diag.is-off .about-diag-readout,
.about-diag.is-off .about-diag-note { display: none; }

/* ---- E4, second pass: buttons on top, results beneath. ----
 *
 * OWNER: "i propose that every button is on top of the page for user
 * convenience, and all diagnostics to be copied beneath."
 *
 * Two blocks, and the separation is the point: `.diag-buttons` is a plain
 * stack, `.diag-results` is a visually recessed panel UNDER it. A measurement
 * block can be very long (five readouts, each several lines), so without a
 * distinct container the buttons would scroll away above a wall of text -- which
 * is the situation the owner is reporting.
 *
 * `.diag-results` is a single child, so it moves as one unit: the owner can see
 * "there is more below" instead of wondering whether the page ended.
 */
.diag-buttons {
  display: flex;
  flex-direction: column;
  gap: 2px;
  /* Keep the controls clear of the sheet's own top edge, so the first button is
     reachable without a scroll on a short page. */
  margin-top: 4px;
}
/* ---- ITEM 2 (2026-10-03): one control per row, with its explanation under
 * it. OWNER: "by adding the instructions under each button it is easier to not
 * by mistake click on the wrong button due the closeness now."
 *
 * The cause is named by the owner, so the fix targets the cause and not the
 * label: the controls were separated by a 2px gap, and a 2px gap is not a
 * separator. Each action is now its own block with a real gap, and the
 * explanation sits inside that block so the visual group is exactly the tap
 * group -- a reader can see which text belongs to which button instead of
 * guessing.
 *
 * The explanation is deliberately a SIBLING of the button and not a child. A
 * <span> inside the <button> would enlarge the tap target, which is the very
 * thing the owner is trying to avoid.
 */
/* ITEM 2, third pass: the programme dropdown. It opens UNDER the control, so
 * it can never cover a neighbouring button -- the owner asked for the numbered
 * tests to be hard to mis-tap, and a popup over them would undo that. */
.diag-list {
  display: flex;
  flex-direction: column;
  margin: 4px 0 0;
  /* Inset to the control's own left edge so it reads as belonging to it. */
  padding-left: 2px;
  border-left: 2px solid var(--accent-soft);
}
.diag-list-item {
  display: block;
  width: 100%;
  text-align: left;
  padding: 9px 10px;
  min-height: 44px;
  font-size: 13px;
  font-weight: 500;
  color: var(--text-secondary);
  background: none;
  border: none;
  border-radius: var(--radius-sm);
  cursor: pointer;
}
/* 44px is the tap-target floor, and the row is a full-width block so the whole
   line is tappable rather than only the text. */
.diag-list-item.selected {
  color: var(--accent);
  font-weight: 700;
  background: var(--accent-soft);
}

.diag-action {
  display: flex;
  flex-direction: column;
  gap: 3px;
  /* The separation the owner asked for. 2px -> 8px between controls; the gap
     INSIDE a control (button to its own hint) is smaller than the gap BETWEEN
     controls, so the grouping reads correctly without any extra border. */
  margin-bottom: 8px;
}
.diag-action:last-child { margin-bottom: 0; }
.diag-action > button { width: 100%; }

/* ---- WS60: the action number, as its own NODE rather than painted into
 * `textContent`.
 *
 * The numbering used to be applied with
 *     btn.textContent = `${n}. ${btn.textContent}`
 * which REPLACED THE BUTTON'S CHILDREN with one text node. Harmless for a button
 * whose label is a bare string — destructive for `ws40Pick`, whose label is a
 * `<span class="setting-minmax ws40-pick-label">` child. The number is therefore
 * a sibling span now, so the label element survives.
 *
 * `display: inline-block` + a fixed `min-width` keeps every label starting at
 * the same x, so the numbers read as a column and the buttons stay aligned —
 * the alignment the owner's numbered list was for, without the node destruction.
 */
.diag-num {
  display: inline-block;
  min-width: 1.1em;
  margin-right: 2px;
  font-weight: 700;
  color: var(--text-secondary);
}
/* The hint is muted and inset to the button's own left edge, so it reads as
   belonging to the button above it rather than as a new control. */
.diag-action > .setting-row-hint { padding-left: 2px; }

.diag-subheading {
  font-size: 12px;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-secondary);
  margin: 18px 0 8px;
}
.diag-results {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 14px;
  padding: 12px 14px;
  /* The measurements are plain text on purpose: they get copied verbatim, and a
     monospace-ish column is far easier to compare between two runs than a
     styled one. Same reasoning as `.about-diag-readout` above. */
  white-space: pre-line;
  word-break: break-word;
}
/* The muted explanation under each button. It is what keeps the developer-facing
   detail after the labels were made plainer -- the wording survives, just moves
   out of the label itself. */
.setting-row-hint {
  display: block;
  font-size: 12px;
  color: var(--text-secondary);
  font-weight: 400;
  margin-top: 1px;
}

/* ---------- mini player ---------- */
/* ---- ITEM 4 (2026-10-03): a little more vertical room in the mid player. ----
 *
 * OWNER: "the midplayer area could benefit from becoming a bit bigger so that
 * the risk of tapping the wrong buttons i smaller. so every row can be getting
 * 1-2 pixels more vertical space now without moving anything horizontally."
 *
 * WHY PADDING AND NOT A HEIGHT. Every row in here is `display: flex` with
 * `align-items: center`, so its height is driven by its tallest child plus its
 * own padding. Raising a row's `padding-block` adds space ABOVE AND BELOW the
 * content and leaves the content's own size and its horizontal box untouched --
 * which is precisely "more vertical space, nothing moved horizontally". Setting
 * an explicit `height` instead would clip or re-centre the content and could
 * change where a label sits.
 *
 * WHY ONLY VERTICAL PROPERTIES APPEAR HERE. The owner's "without moving
 * anything horizontally" is a real constraint, not a figure of speech: this
 * panel is a shared text column whose alignment is STRUCTURAL (see WS13 Part A
 * below and WS19 -- `margin-left: var(--player-col)` is what lines the title,
 * the pill and the song up on the artwork's edge). Touching a horizontal value
 * here would move that column for every row at once. So: `padding-block` and
 * `row-gap` only. `padding` shorthand is deliberately NOT used, because its
 * horizontal components would be easy to change by accident.
 */
.player {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 30;
  background: var(--surface);
  box-shadow: 0 -4px 20px rgba(28, 28, 30, 0.12);
  /* Was `10px 16px ...`. Only the BLOCK values change; the 16px inline padding
     is byte-identical so nothing moves sideways.
     WS73 (owner, 2026-10-07): "the rows still feel a bit too tight ... increase
     the rows a bit more vertically". Block padding 11 -> 13 top and +13 -> +15
     bottom. Vertical-only, same discipline as WS68.
     WS74 (owner, 2026-10-08): the owner then reported the change was not
     VISIBLE (+7% on a tall panel). 13 -> 16 top and +15 -> +18 bottom.
     WS75 (owner, 2026-10-08): 16 -> 20 top and +18 -> +22 bottom. The dominant
     visible change this pass is the icon-sized cover (below) and the hidden
     build number; this is the matching vertical step, still padding-block only. */
  padding: 20px 16px calc(var(--safe-bottom) + 22px);
  /* ---- WS68: more vertical room in the player rows ----
   *
   * OWNER: "the player where i have also asked for some more space vertically".
   * The constraint is VERTICAL-ONLY: the horizontal padding (16px) is
   * byte-identical, so nothing moves sideways and no row can wrap. The rows get
   * their breathing room from line-height, not from padding, because padding on
   * the rows would push the transport down and change the player's total height
   * in a way the owner did not ask for.
   */
  /* ---- WS73: the rows get more room, and the gap becomes real ----
   *
   * OWNER, 2026-10-07: "the rows still feel a bit too tight ... increase the
   * rows a bit more vertically". WS68 introduced this constant and the
   * compaction reset below (`.player-meta { margin-top: 3px }` on the pill)
   * absorbed it, so the "gap" was never consumed. WS73 makes .player-meta a
   * flex COLUMN so the value actually separates the identity row from the
   * quality pill, and the pill's compensation margin is removed.
   * WS74 (owner, 2026-10-08): 8 -> 11px. The owner reported the WS73 step was
   * not visible on the device.
   */
  --player-row-gap: 11px;
  transform: translateY(110%);
  transition: transform 0.25s ease;
  max-width: 480px;
  margin: 0 auto;
  border-radius: 16px 16px 0 0;
  /* The player owns vertical gestures on itself (expand/minimize drags).
     Without this, iOS scrolls the page behind while the finger drags —
     "everything moves" flakiness (user report 2026-09-23). */
  touch-action: none;

  /* ---- WS5b: one shared text column for the whole player ----
     The channel, the programme, the quality/mode pills and the song line all
     have to start at the SAME x, and they have to stay there when the artwork
     or the spacing changes. So the column is derived once, here, from the
     artwork width plus the row gap, and everything that needs it reuses the
     variables instead of repeating a pixel literal. Change --player-art or
     --player-gap and the header, the pills and the song line move together;
     a duplicated literal would have let them drift apart silently. */
  /* ---- WS75 CORRECTION (owner, 2026-10-09): the NORMAL player's cover stays
   * 44px "as it was". WS75 first made it icon-sized (var(--icon), 77px), which
   * was the OPPOSITE of the instruction -- the icon-sized cover belongs to the
   * EXTENDED player (see .expand-img below), not this one. Reverted to the
   * literal 44px. */
  --player-art: 44px;
  --player-gap: 12px;
  /* Where the text column starts, measured from the player's own left padding
     edge. The header achieves it structurally: the spacer occupies the
     artwork's width, so flexbox puts the text exactly here. */
  --player-col: calc(var(--player-art) + var(--player-gap));
}

/* While a vertical gesture is being read, hard-lock page scroll as well
   (belt + suspenders for iOS Safari's rubber-banding). */
body.player-gesture-lock {
  overflow: hidden;
  overscroll-behavior: none;
}

/* ---- minimized mini-bar (2026-09-23) ---- */
/* Swipe down on the player minimizes it: compact bar, page visible,
   audio keeps playing. */
.player.minimized {
  padding: 8px 12px calc(var(--safe-bottom) + 8px);
  border-radius: 16px 16px 0 0;
}
.player-mini {
  display: flex;
  align-items: center;
  gap: 10px;
}
.player-mini .player-thumb { width: 40px; height: 40px; flex: none; }
.player-mini .player-meta { cursor: pointer; }
/* ---- WS73: the mini bar keeps its own, tighter geometry. ----
   The owner's "the rows still feel a bit too tight" was about the FULL player.
   The mini bar renders the SAME `.player-meta` class with three children
   (title, sub, now-playing-line), so the flex column + row-gap added for the
   full player would have grown it from 40px to 46.8px -- a 17% taller compact
   bar nobody asked for. Measured in the driven DOM both ways. Reset to the
   block layout so the mini bar is byte-identical to before. */
.player-mini .player-meta { display: block; row-gap: normal; }
.player-mini .player-title {
  font-size: 13.5px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.player-mini .player-sub {
  font-size: 11.5px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* WS15b: in the minimised bar the podcast name takes the sub line's place, so
   it must match .player-mini .player-sub exactly -- 11.5px, not the full
   player's 11px -- or the two bars disagree on the same string. Same clip
   rules: the mini bar's text column is even narrower than the full player's,
   so ellipsis rather than wrap, or the bar grows a row. */
.player-mini .player-podcast-name {
  font-size: 11.5px;
  color: var(--text-secondary);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.player-mini .player-btn { width: 38px; height: 38px; }
.player-mini .player-btn-main { width: 40px; height: 40px; }
.player-mini .player-btn-main svg { width: 20px; height: 20px; }
.player-mini .player-btn-close { width: 30px; height: 30px; }
.player-mini .player-btn-close svg { width: 14px; height: 14px; }

.player.visible { transform: translateY(0); }

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

/* WS12 Part A: the build line is a plain block at the bottom of the content
   flow again, directly under NYHETER. WS11 Part B moved it into .topbar, which
   was the reviewer's decision rather than the owner's; the owner saw it and
   asked for it back, and the cog wheel to return to the right edge with it.
   (With three children, `.topbar`'s space-between centred the cog.) */
/* ---- WS80 (owner, 2026-10-09): the .build-line rules are REMOVED with the element.
 *
 * OWNER: "the build id should not be present under Nyheter anymore as it is not
 * now. it is enough that the build id is visible under Info."
 *
 * The paragraph that these rules styled is deleted from app.js (see the
 * renderMain comment), so the block rule and the `body:has(.player.visible)
 * .build-line { display: none }` rule below it have nothing left to apply to.
 * Removing dead selectors keeps the "one concept, one implementation" rule: a
 * rule that matches nothing is a trap for the next reader, who cannot tell
 * whether the element was renamed or genuinely retired.
 *
 * The build id is still shown, in the About overlay only. */

/* ---------- WS5b: header row with the corner buttons ---------- */
/* ONE row carries the close button (top-left corner), the channel and
   programme (one line, sharing the text column), and the expand chevron
   (top-right corner). WS5 had a separate row for the two buttons, which left
   them floating in the middle of the player with dead space beside them; the
   owner read that as unclear on a real iPhone.

   Vertical placement is flexbox (`align-items: center`), never a hand-tuned
   `top`/`margin-top` — a pixel offset drifts the moment the button height, the
   font metrics or the safe-area inset change.

   The text column is NOT a `padding-left` literal: the close button is given
   the artwork's own width, so the gap after it is the same `--player-gap`
   the pills use and the text lands on their left edge by construction. */
.player-header {
  display: flex;
  align-items: center;
  gap: var(--player-gap);
  min-width: 0;
  /* WS77: positioning context for the sleep-timer watch, which is an absolute
     overlay at the header's left (see .player-sleep-btn). Without this the
     button would resolve against .player and sit at the player's top corner. */
  position: relative;
  /* ITEM 4: was `0 0 4px` (measured 36px tall). +2px below the header text, so
     the title block is less likely to be read as belonging to the row under it.
     Vertical only; the inline values stay 0.
     WS74 (owner, 2026-10-08): 6 -> 9px, part of the second, visible pass at
     "increase the rows a bit more vertically". */
  padding: 0 0 9px;
}
/* ---- WS13 Part A: the header holds a WIDTH-ONLY SPACER ----
   The close button moved to the transport row, at the owner's request, to
   mimic the minimised bar (thumb . text . play . chevron . close).

   It used to sit HERE and double as the artwork-column spacer: sized from
   --player-art, with the row's shared --player-gap after it, it is what lands
   the title and the programme on the artwork's right edge — the same left edge
   as the quality pill and the song line. That alignment is STRUCTURAL, not a
   padding-left literal, so it must survive the button leaving.

   The spacer is that same width, from the same custom property. Never a pixel
   literal: 44px would drift the moment --player-art changes, which is the one
   thing this design exists to prevent.

   EXACTLY ONE element may occupy that column. The real button and this spacer
   must never both be present — that would add 44px of dead space and push the
   text right of the artwork edge.

   Width only, and deliberately NO height: the spacer exists to hold the
   column, not to keep a row of buttons tall, so the header still collapses to
   the text. */
.player-header .player-header-spacer {
  width: var(--player-art);
  flex: none;
}
/* ---- WS14: chevron + close return to the header, RIGHT-grouped ----
   The owner (2026-09-27) asked for the minimised bar's arrangement: both
   buttons on the same line as the channel and programme title, after the
   text, for radio and podcasts alike.

   32px each, not 44px. The header row is NOT a button row -- it is a text row
   with two buttons at the end -- and 44px buttons would leave the programme
   title only 358 - 44(spacer) - 24(gaps) - 88(buttons) = 202px on a 390px
   iPhone. At 32px it keeps 222px, and the buttons still read as tappable
   because they sit in a --player-art-tall line box. Never let these grow back
   to var(--player-art) without re-measuring the title at 360px.

   `margin-left: auto` on the chevron pushes the PAIR to the far right, past
   the shrinking text, rather than relying on a fixed width for the text. The
   pair then travels together, which is the minimised bar's look. */
.player-header .player-expand-btn,
.player-header .player-btn-close {
  width: 32px;
  height: 32px;
  flex: none;
}
.player-header .player-expand-btn { margin-left: auto; }
.player-header .player-btn-close { color: var(--text); background: var(--border); }
/* The two SVGs shrink with the buttons; the default .player-btn rule sizes
   icons at 22px, which fills a 32px circle. */
.player-header .player-expand-btn svg { width: 18px; height: 18px; }
.player-header .player-btn-close svg { width: 16px; height: 16px; }

/* ---- WS77 (owner, 2026-10-09): the sleep-timer WATCH, mirroring the close ----
 * OWNER: "on the same position as the round cross icon but on the left side of
 * the player i want you to add an icon for a round watch ... the icon design
 * should be the same as for the close button exactly size and colour wise. only
 * difference on the button is that the x should be a round watch."
 *
 * Same 32px size and the same colour treatment as the close button (which is
 * --text on --border, matching the sheet's ✕), so the pair reads as one.
 *
 * ABSOLUTELY positioned at the header's LEFT rather than a flex child: the
 * header's text column is STRUCTURAL -- the width-only spacer holds the artwork
 * column and exactly ONE element may occupy it (WS13 Part A) -- so a flex
 * button here would shrink the column by 12px and pull the programme text and
 * the quality pill off the artwork's right edge. As an overlay it lands on the
 * header's left edge, the same edge as the album art below, and leaves the text
 * where it is. The spacer stays, so the column does not move. */
.player-header .player-sleep-btn {
  width: 32px;
  height: 32px;
  flex: none;
  position: absolute;
  left: 0;
  /* Vertically centred on the CLOSE BUTTON's line, not on the header's padding
   * box. The header is `padding: 0 0 9px`, so its flex children (close, chevron)
   * centre in the CONTENT box, which is 4.5px above the padding box's `50%`. A
   * plain `top: 50%` therefore landed the watch 4.5px below the close --
   * MEASURED (watchCentreY 719.8 vs closeCentreY 715.3). The subtract is half
   * that bottom padding; if the header's padding changes, change this with it. */
  top: calc(50% - 4.5px);
  transform: translateY(-50%);
  color: var(--text);
  background: var(--border);
}
.player-header .player-sleep-btn svg { width: 16px; height: 16px; }
/* Armed: filled in accent so the owner can see at a glance that it is running. */
.player-header .player-sleep-btn.active {
  background: var(--accent);
  color: #fff;
}
/* The programme line takes the full remaining width and ellipsizes. No
   max-width percentage: the old 40% cap truncated the channel name on narrow
   phones for no reason.
   ---- WS19: `.player-header .player-title` is DEAD and was deleted here.
   The bold channel title left the header (it duplicated the meta row above
   the quality pill), and the mini-bar's own title is not matched by this
   scoped selector, so nothing styles it any more. Left behind, the rule would
   tell a future reader the header still has two text cells to make room for. */
.player-header .player-sub { flex: 1 1 auto; min-width: 0; }

.player-row {
  /* ---- WS75 CORRECTION (owner, 2026-10-09): back to the flex row ----
   * WS75 briefly made this a 2-column grid, because it had (wrongly) made the
   * NORMAL player's cover icon-sized (77px), which squeezed the text column to
   * 21px and clipped the quality pill. The owner's correction puts the
   * icon-sized cover in the EXTENDED player instead and restores this cover to
   * 44px, so the original single-row flex layout fits again and is restored
   * exactly as it was. */
  display: flex;
  align-items: center;
  gap: var(--player-gap);
}

.player-close {
  margin-left: auto;
}

.player-thumb {
  width: var(--player-art);
  height: var(--player-art);
  border-radius: 10px;
  overflow: hidden;
  flex: none;
  background: var(--accent-soft);
  display: flex;
  align-items: center;
  justify-content: center;
}
.player-thumb img { width: 100%; height: 100%; object-fit: cover; }
.player-thumb-letter { color: var(--accent); font-weight: 700; font-size: 18px; }

.player-meta {
  min-width: 0;
  flex: 1;
  /* ---- WS73: the two rows are separated by the gap ----
     `.player-meta` was a block container whose children stacked with only the
     quality pill's own margin between them. The owner asked for more vertical
     room; making this a flex column lets --player-row-gap do the separating,
     which is the mechanism WS68 introduced the constant for. Kept in ONE rule
     on purpose -- a second `.player-meta {` block would split the styling for
     any reader or guard scanning from the first occurrence (the same trap the
     quality-pill comment below describes). */
  display: flex;
  flex-direction: column;
  row-gap: var(--player-row-gap);
}

/* ---- WS15: the podcast name, above the quality pill ----
   Owner (2026-09-27): "the podcast name in the player should move to the
   space between 'Kehlani...' and pill MP3". The header carries the EPISODE
   name on a podcast, and the podcast name was previously only visible as the
   small grey subtitle in the expanded panel. This puts it in the compact
   player, directly above the pill.

   Width-only discipline, same as .player-header-spacer: `.player-meta` is
   `flex: 1 1 0%` with `min-width: 0`, so it absorbs a width deficit all the
   way to ZERO and never pushes back. A long podcast name here must therefore
   CLIP, never wrap — a wrap would raise the row height and the pill below it
   would move, which is the WS9/WS11/WS12 class of regression. `nowrap` plus
   `text-overflow: ellipsis` degrades it the way the pills do.

   Deliberately NOT reusing .player-sub: that element is load-bearing
   (paintProgramTitle writes into it and the WS0 snapshot reads it).

   Sits at 11px in the secondary colour, between the 14px header title and the
   10px pill — it is context, not the primary label. */
.player-podcast-name {
  font-size: 11px;
  line-height: 1.35;
  color: var(--text-secondary);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 100%;
}

/* ---- WS16: the two-row structure, identical for radio and podcasts ----
   .player-meta is a block container with two children, and they stack as rows:
     row 1   .player-meta-row  -- identity name + time state at the far right
     row 2   .player-quality   -- the codec pill

   `.player-meta-row` is an explicit flex row rather than two siblings in a
   block context, because the time state must reach the RIGHT EDGE and
   `margin-left: auto` only has anything to consume inside a flex line. As
   siblings in a block formatting context the auto margin is absorbed by the
   inline space and the pill sits immediately after the name.

   A podcast has no time state, so its row 1 has a single child and lays out
   exactly as before. The pill is then the only visual difference between the
   two kinds, and that is real information, not a layout accident.

   `.player-quality` becomes `display: block` + `width: max-content` so it
   occupies row 2 by itself and keeps its pill shape. Source order alone would
   also put it there, but relying on that is the kind of coincidence that
   breaks silently the moment a fourth child appears.

   `min-width: 0` on the name stops the time state from being squeezed instead
   of the name -- the name is the one that carries the identity. */
.player-meta-row {
  display: flex;
  align-items: center;
  gap: var(--player-gap);
  min-width: 0;
  /* WS68: vertical-only. The row's height comes from its line boxes, so the
   * extra room the owner asked for is line-height, not padding -- padding here
   * would grow the player's total height and push the transport down.
   * WS73 (owner, 2026-10-07): "the rows still feel a bit too tight". Floor
   * 22 -> 26px, line-height 1.45 -> 1.55. Vertical-only, same reasoning.
   * WS74 (owner, 2026-10-08): the owner reported the WS73 step was not
   * visible. Floor 26 -> 30px, line-height 1.55 -> 1.65.
   * WS75 (owner, 2026-10-08): floor 30 -> 34px, matching the taller cover. */
  min-height: 34px;
  line-height: 1.65;
}
.player-meta-row > .player-podcast-name { min-width: 0; }
/* Compound selector on purpose. A bare base-selector block placed earlier in
   the file becomes the FIRST occurrence of that selector string, and both
   region() (which searches forward) and any reader scanning for the real rule
   would find this three-property fragment instead of the styling further
   down. Same trap bit the quality pill -- see the note on that rule.

   The time state is pulled to the right edge of row 1 with `margin-left:
   auto`, which only consumes space inside a flex line. */
.player-meta-row > .player-mode {
  margin-left: auto;
  flex: none;
}

.player-title {
  font-size: 14px;
  font-weight: 600;
  /* WS68: vertical-only breathing room. 1.3 -> 1.45 adds ~2px per line without
   * moving anything horizontally, and the nowrap/ellipsis guards below are
   * untouched so a long title still clips rather than wrapping to a second
   * line -- a wrap would raise the row height and defeat the point.
   * WS73 (owner, 2026-10-07): 1.45 -> 1.55, same vertical-only reasoning.
   * WS74 (owner, 2026-10-08): 1.55 -> 1.65 — the previous step was not
   * visible to the owner. */
  line-height: 1.65;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.player-sub {
  font-size: 12px;
  color: var(--text-secondary);
  /* WS68: vertical-only, same reasoning as .player-title.
     WS73 (owner, 2026-10-07): 1.45 -> 1.55.
     WS74 (owner, 2026-10-08): 1.55 -> 1.65, same as the title above. */
  line-height: 1.65;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.player-quality {
  /* WS16: the pill now occupies ROW 2 of .player-meta on its own, in the same
     place the podcast's MP3 pill already had. `block` + `max-content` states
     that explicitly rather than relying on source order. The base rule below is
     the one place this styling lives: a second `.player-quality {` block would
     split the rule for any test or reader scanning from the first occurrence. */
  display: block;
  width: max-content;
  max-width: 100%;
  /* WS73: the 3px compensation margin is REMOVED. It existed to top up the
     spacing in a block container; with .player-meta now a flex column carrying
     `row-gap: var(--player-row-gap)`, the gap is the only separator and the
     pill's own margin would double it. */
  margin-right: 5px;
  padding: 1px 7px;
  border-radius: 999px;
  background: var(--accent-soft);
  color: var(--accent);
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.04em;
  line-height: 1.5;
  /* WS12 Part B DEFENCE IN DEPTH — this is a guard, NOT the cure.
     The cure was restoring the width: in the DVR state .player-meta was
     squeezed to 0px and this pill wrapped onto two lines (17px -> 32px tall).
     `.player-title` and `.player-sub` both set nowrap, so they degraded to an
     ellipsis; this pill did not, so it wrapped instead. Same guard as those
     two: if the column is ever squeezed again, clip rather than wrap. */
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Mode pill: where in time am I (LIVE now; DVR states in Phase 3) */
.player-mode {
  display: inline-block;
  margin-top: 3px;
  padding: 1px 7px;
  border-radius: 999px;
  background: var(--border);
  color: var(--text-secondary);
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.04em;
  line-height: 1.5;
  /* Same WS12 Part B defence as .player-quality: never wrap to a second line. */
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Buffering: badge pulses while audio loads or re-buffers */
.player-quality.buffering {
  animation: badge-pulse 1.1s ease-in-out infinite;
}
@keyframes badge-pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.45; }
}
@media (prefers-reduced-motion: reduce) {
  .player-quality.buffering { animation: none; }
}

.player-controls {
  display: flex;
  align-items: center;
  /* OWNER, 2026-10-07: "the +- 15 seconds arrows would benefit from moving a
     bit from the play button horizontally. so ... give some more room for the
     arrows". 4px -> 8px, doubling the arrow-to-play room.

     WHY NOT MORE, MEASURED. `.player-controls` shares `.player-row` with the
     text column (thumb 44 + gap 12 + meta + gap 12 + controls = 358px at a
     390px viewport), and the controls are `flex: none` -- so every pixel the
     gap takes comes out of the title and the quality pill. A DVR channel adds
     TWO programme-skip buttons, making a five-button row. Measured in the
     driven DOM with that widest row:
         gap 4  -> controls 220px, text column 70.1px, pill "MP3 96" (57.9px) fits
         gap 12 -> controls 252px, text column 38.1px, pill SPILLS past its box
     12px therefore reintroduces the WS12 squeezed-column defect ("the pill
     wrapped onto two lines ... 17px -> 32px") on any DVR channel. 8px is the
     largest value that keeps the pill intact at 375, 390 and 430px -- all
     three measured, none spilling, no overlap with the first transport button.

     WS74 (owner, 2026-10-08, iPhone 13): the owner reported 8px was still not
     visible. 8 -> 12px. MEASURED at 390px (the iPhone 13 width) with the widest
     row -- five-button DVR plus the 50.8px "MP3 96" pill: the text column gets
     54.1px, so the pill still fits with room to spare. At 375px the column is
     47.2px and the pill CLIPS to one ellipsised line rather than wrapping (the
     WS12 degradation), which is the honest ceiling of this layout: five buttons
     plus gaps plus the 44px artwork leave only ~39px for text at 375px. */
  gap: 12px;
  flex: none;
}

.player-btn {
  width: 44px;
  height: 44px;
  border-radius: 50%;
  border: none;
  background: transparent;
  color: var(--text);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}
.player-btn svg { width: 22px; height: 22px; }
.player-btn-main {
  background: var(--accent);
  color: #fff;
}
.player-btn-main svg { width: 24px; height: 24px; }
.player-btn-close {
  color: var(--text);
  background: var(--border);
  width: 32px;
  height: 32px;
  border-radius: 50%;
}
.player-btn-close svg { width: 16px; height: 16px; }

/* WS11 Part A: the .dvr-window-readout rules are REMOVED with the element.
   The readout was a third flex child of .seek-row and therefore took width
   from the seek bar (WS9 regression, found on the device). The value remains
   available in the gated diagnostics snapshot -- press.windowSeconds -- which
   renders nothing on screen. */

.seek-row {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-top: 8px;
}

.player-time {
  font-size: 11px;
  font-variant-numeric: tabular-nums;
  color: var(--text-secondary);
  min-width: 34px;
  text-align: center;
}

.seek-bar {
  flex: 1;
  height: 22px; /* generous touch target */
  display: flex;
  align-items: center;
  cursor: pointer;
  position: relative;
}

/* Episode seek allows vertical page gestures while handling horizontal drag. */
.episode-seek-bar {
  touch-action: pan-y;
  -webkit-user-select: none;
  user-select: none;
}

.seek-bar::before {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  height: 4px;
  transform: translateY(-50%);
  border-radius: 2px;
  background: var(--border);
}

.seek-fill {
  /* ---- WS81 (owner, 2026-10-09): the fill must use the TRACK's reference ----
   * OWNER: "in the 773b2e5 it doesn't look perfect". MEASURED: the dot sat to
   * the RIGHT of the fill end by exactly `position x 14px` -- about +12px at the
   * owner's slider position, +14px at the live edge (worst exactly when the
   * offset badge is on screen).
   *
   * CAUSE -- one position, two references:
   *   - the TRACK (`.seek-bar::before`, `left:0; right:0`) and the THUMB
   *     (`position: absolute`) resolve against the bar's PADDING box;
   *   - the fill was the only NORMAL-FLOW child, so `width: f%` resolved against
   *     the bar's CONTENT box -- which `.dvr-bar { padding-right: 14px }` (BUG B)
   *     shrinks by exactly 14px.
   *
   * THE FIX: give the fill the same box as the other two by making it absolutely
   * positioned across the track. `padding-right` does NOT change the padding box,
   * so this makes the fill, the track and the thumb share one reference BY
   * CONSTRUCTION -- no per-frame ratio, no hardcoded 14, and it cannot drift
   * again if the padding is ever retuned. `padding-right` is left exactly as it
   * was, so BUG B's guarantee is untouched.
   *
   * (A JS fix was tried first and rejected: it needed a live track/content width
   * ratio recomputed per bar, and a mutation check showed a hardcoded ratio would
   * pass silently. Making the boxes agree removes the arithmetic entirely.) */
  position: absolute;
  left: 0;
  top: 50%;
  transform: translateY(-50%);
  height: 4px;
  width: 0;
  border-radius: 2px;
  background: var(--accent);
  z-index: 1;
}

/* ---------- DVR seek row (Phase 3, UX rev) ---------- */
/* Real draggable slider: thumb follows the fill; left label = clock time of
   the heard position; right = clickable LIVE label (returns to live edge). */
.dvr-row { gap: 8px; }

/* BUG B (2026-09-22 iPhone feedback): the bar must own all touch gestures —
   without touch-action:none, Safari's touch scroll/gesture recognition fights
   the pointer drag and the slider feels spotty/unstable. */
* {
  /* Kill double-tap zoom everywhere while keeping pinch-zoom (two fingers)
     fully functional — 'manipulation' allows panning + pinch but disables
     the double-tap-zoom gesture. Fixes unwanted zoom when double-clicking
     the player (user report 2026-09-23). */
  touch-action: manipulation;
}

/* Elements that own custom gestures re-declare finer control: */
.dvr-bar { touch-action: none; -webkit-user-select: none; user-select: none; }

/* 2026-09-23: the LIVE label button was removed from the DVR row — the mode
   pill shows LIVE/−time and a tap on the bar's right 12 % returns to live.
   The bar keeps its right padding so the thumb has breathing room. */
/* ---- WS81 (owner, 2026-10-09): the dot was drifting right of the fill ----\n * OWNER: \"in the 773b2e5 it doesn't look perfect\" -- and MEASURED, the dot sat\n * to the RIGHT of the fill end by exactly `position x 14px` (about +12px at the\n * owner's slider position, +14px at the live edge).\n *\n * THE CAUSE, and it is the AGENTS.md section 3 pattern: ONE position measured\n * against TWO different references, both inside the bar.\n *   - the TRACK (`.seek-bar::before`, `left:0; right:0`) and the THUMB\n *     (`position:absolute; left:f%`) resolve against the bar's PADDING box\n *   - the FILL is a normal-flow child, so `width: f%` resolves against the\n *     bar's CONTENT box = padding box width - 14px.\n * So the track and the thumb share one reference and the FILL had a different\n * one -- the fill ran 14px short, which is exactly the `f x 14px` drift, growing\n * as you approach live, i.e. precisely when the offset badge is on screen and\n * the dot is furthest from where the fill says you are.\n *\n * THE FIX is in the paint maths, not here: both bars now write the fill width as\n * a percentage OF THE CONTENT BOX, so the fill, the thumb and the track all\n * describe the same position. Doing it in JS rather than CSS is what keeps BUG\n * B's guarantee intact -- the padding-right is untouched, so the thumb still\n * cannot reach the LIVE label zone. (A CSS-only attempt using a negative\n * margin-right \"worked\" for the drift but shifted the track 14px right of the\n * content box and under the LIVE pill, which is trading one defect for another.) */
.dvr-bar { padding-right: 14px; }

.seek-thumb {
  position: absolute;
  top: 50%;
  left: 100%;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: var(--accent);
  border: 2px solid var(--surface);
  box-shadow: 0 1px 4px rgba(28, 28, 30, 0.35);
  transform: translate(-50%, -50%);
  z-index: 2;
  pointer-events: none; /* the bar handles the pointer */
  transition: transform 0.12s ease;
}
/* ---- WS78/WS80 (owner, 2026-10-09): a ring around the thumb dot ----
 * OWNER (WS78): "add for the slider a ring that is not too big around the
 * current dot ... and make the ring area as well possible to grab to make it
 * easier for users."
 * OWNER (WS80, after seeing it): "the ring around the slider is a little too big
 * and needs the solid background colour of the player."
 *
 * Two corrections from WS78's 26px hollow ring:
 *
 * 1. SIZE 26 -> 22px. The owner's words are "a little too big", so this is a
 *    step, not a redesign: 22px still clears the 14px dot by 4px on the radius
 *    and is still comfortably grabbable, while reading far less like a target
 *    ring drawn around the slider. (`--player-art` and `.dvr-step-btn` are 36px
 *    and `.dvr-program-btn::before` is 30x26, so 22px also keeps the slider
 *    ring the SMALLEST of the app's mark-out shapes -- which is what it is.)
 *
 * 2. FILL with `var(--surface)`. "the solid background colour of the player" is
 *    exactly `--surface`: that is the token `.player { background: var(--surface) }`
 *    uses. With the fill the interior is opaque, so the 4px track no longer
 *    shows THROUGH the ring -- which is what made the hollow version read as a
 *    window rather than a handle. `border: 1.5px solid var(--accent)` is
 *    unchanged, so the accent outline the owner asked for is still there.
 *
 * Still drawn on the BASE class so BOTH sliders (live DVR and podcast) show it,
 * centred on each thumb regardless of that thumb box's own size -- so the
 * ring-to-dot gap is identical on each. `pointer-events: none` on the RING keeps
 * the grab decision on the thumb box below (see `.dvr-bar .seek-thumb`). */
.seek-thumb::before {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  width: 22px;
  height: 22px;
  margin: -11px 0 0 -11px;
  border-radius: 50%;
  border: 1.5px solid var(--accent);
  background: var(--surface);
  pointer-events: none;
}
.episode-seek-bar .seek-thumb {
  /* Larger invisible hit area without changing the visible 14px thumb. */
  width: 32px;
  height: 32px;
  background: transparent;
  border: 0;
  box-shadow: none;
  pointer-events: auto;
  touch-action: pan-y;
}
.episode-seek-bar .seek-thumb::after {
  content: '';
  position: absolute;
  inset: 9px;
  border-radius: 50%;
  background: var(--accent);
  border: 2px solid var(--surface);
  box-shadow: 0 1px 4px rgba(28, 28, 30, 0.35);
  pointer-events: none;
}
.episode-seek-bar.dragging .seek-thumb::after {
  transform: scale(1.15);
}
/* ---- WS78: the DVR thumb is grabbable, and its dot is drawn by ::after ----
 * OWNER: "make the ring area as well possible to grab to make it easier for
 * users".
 *
 * The base thumb is 14px and `pointer-events: none`, so on the live radio
 * slider the only way to grab was the bar itself. Here the thumb box grows to
 * 26px -- the ring's own size, so the ring IS the grab target -- while the
 * visible dot stays 14px (drawn by ::after, exactly as the podcast slider
 * already does). `pointer-events: auto` makes the thumb catch the press; the
 * event still bubbles to the bar's own pointerdown, which runs the SAME drag
 * (it computes the fraction from the bar's rect, so a press on the thumb seeks
 * to the thumb, as expected). The vertical-swipe disambiguation is unchanged --
 * a vertical gesture that starts on the thumb is still handed to swipe-to-close. */
.dvr-bar .seek-thumb {
  /* WS80: the box follows the ring down 26 -> 22px, so the ring IS the grab
     target (the guard asserts box >= ring in BOTH dimensions -- the promise
     "the ring area is grabbable" is only true if it does). */
  width: 22px;
  height: 22px;
  background: transparent;
  border: 0;
  box-shadow: none;
  pointer-events: auto;
}
.dvr-bar .seek-thumb::after {
  content: '';
  position: absolute;
  /* The visible dot stays 14px inside the 22px box (22 - 2*4 = 14), exactly as
     it was: enlarging the HIT area must not enlarge the visible thumb. */
  inset: 4px;
  border-radius: 50%;
  background: var(--accent);
  border: 2px solid var(--surface);
  box-shadow: 0 1px 4px rgba(28, 28, 30, 0.35);
  pointer-events: none;
}
.dvr-bar.dragging .seek-thumb { transform: translate(-50%, -50%) scale(1.25); }
@media (prefers-reduced-motion: reduce) {
  .seek-thumb { transition: none; }
}

/* DVR transport buttons: ±15 s steps + program skip now live in the MAIN
   controls row flanking play/pause (2026-09-23 repositioning). The
   .dvr-step-btn class is kept for the ±15 s icon styling there. */
.dvr-step-btn {
  border: none;
  background: transparent;
  color: var(--accent);
  width: 36px;
  height: 44px;
  padding: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  flex: none;
  /* ---- WS76 (owner, 2026-10-09): the ±15 s button is a thin green CIRCLE ----
   * The owner: "make a thin round circular marking in the same green accent
   * colour as the circular arrows around them". The button is 36 wide x 44
   * tall, so a CSS `border` would draw an ELLIPSE, not a circle. The ring is
   * therefore an absolutely-positioned square pseudo-element (the same
   * technique the playing icon ring uses): 36 x 36, centred on the button's
   * own centre, border-radius 50%, so it is a true circle.
   *
   * It also makes the whole circle clickable in the visual sense the owner
   * asked for: the button's hit area is still its own 36 x 44 box, but the
   * visible circle now reads as the thing you press. (No pointer-events trick
   * is used -- the ring is part of the button, so a tap anywhere on the button,
   * circle or padding, works.) */
  position: relative;
}
.dvr-step-btn::before {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  width: 36px;
  height: 36px;
  margin: -18px 0 0 -18px;
  border: 1.5px solid var(--accent);
  border-radius: 50%;
  pointer-events: none;
}
.dvr-step-btn svg { width: 20px; height: 20px; position: relative; }
/* ---- WS80 (owner, 2026-10-09): the ±15 s ink is centred in its ring ----
 * OWNER, third report: "the circle arrows are still not fully centered."
 *
 * WS79 removed WS77's UPWARD nudge (-0.83px) after finding the ink's mass
 * centroid already sat 0.13px below the ring centre. That was right about the
 * centroid and WRONG about what the owner sees: the salient bounding edge is
 * still 1.67px out.
 *
 * MEASURED on the built page, both arrows identical (`getBoundingClientRect` of
 * the ink vs the ring's own rect; the glyph is a 24-unit viewBox drawn in 20px,
 * so 1 unit = 0.833px):
 *   ink bbox in viewBox units   y 15.4 .. 35.4   (ring spans 13.2 .. 49.2)
 *   clearance to the ring       top 8.83px, bottom 10.50px   ->  1.67px unequal
 * The bbox IS what the eye compares against the ring's edge, and the original
 * WS77 direction was right after all -- just under-corrected and, in WS79,
 * removed. Nudging the glyph DOWN by half the 0.833px unit (one viewBox unit
 * = 0.83px) equalises the clearances:
 *   after   top 9.66px, bottom 9.67px   ->  0.01px (measured), i.e. concentric.
 *
 * So the correct answer is +0.83px DOWN, which is neither WS77 (-0.83px up) nor
 * WS79 (nothing). A transform, so nothing reflows. */
.player .dvr-step-btn svg { transform: translateY(0.83px); }

/* ---- WS79 (owner, 2026-10-09): the ±15 s glyph was NOT to be nudged UP ----
 * OWNER, second report: "check also again the centering for the circular
 * arrows. as you see attached the visual is not centered."
 *
 * WS77 HAD ADDED a nudge here (`translateY(-0.83px)`), on the reasoning that the
 * glyph's ARC circle is centred at viewBox y=13 while the ring is at y=12. That
 * reasoning picked the WRONG anchor and the nudge made the misalignment WORSE.
 *
 * MEASURED, on the built page, by sampling the actual path (isPointInFill at a
 * 1/20-unit grid) and by reading getBBox -- the same numbers for both arrows:
 *
 *   ring centre            viewBox y = 12      (the 36x36 ring is centred on the
 *                                               36x44 button's own centre)
 *   ink BBOX            y 1.025 .. 20.975  ->  centre 11.0   = 0.83px ABOVE
 *   ink MASS centroid                      ->  12.15         = 0.13px BELOW
 *
 * The bbox centre is pulled UP by the arrowhead's thin bar reaching y=1. What
 * the eye reads as the shape's centre is the ink's MASS, not the extreme of one
 * thin spike -- and the mass sits 0.13px below the ring centre, i.e. already
 * centred for any practical purpose (0.13px is a tenth of a device pixel).
 *
 * WS77's -0.83px then moved the mass to 0.70px ABOVE the ring centre. The owner
 * saw exactly that: the correction was worse than the original.
 *
 * SO: no transform. Measured centroids 12.155 (back) and 12.158 (forward) vs a
 * ring centre of 12.00 -- a residual of 0.13/0.16px, which is under a fifth of
 * one device pixel on the owner's 3x iPhone screen and is not perceptible.
 * The button's own flex centring (align-items:center) puts the 20px box on the
 * 44px button's centre, which is the ring's centre, so nothing else is needed.
 *
 * Guarded by tests/ws78-skip-rect-and-thumb-ring.test.mjs, which now asserts
 * the glyph carries NO vertical displacement (the property the owner reported).
 * The WS77 guard that required an upward nudge was restated per AGENTS.md 7b --
 * it guarded the wrong property, and a red test that means something is worth
 * more than a green one that means nothing. */
.dvr-step-btn:active { opacity: 0.6; }
/* WS73 (owner, 2026-10-07): the programme-skip buttons narrow from 44px to the
   36px the ±15 s buttons already use, which pays for the wider transport gap
   above. MEASURED with the five-button DVR row: at a 390px viewport the row's
   total width is unchanged (220px) while the gap between every pair of arrows
   doubles from 4px to 8px, and the quality pill keeps its full 50.8px box.
   Before this, the five-button row was the only case where the gap could not
   grow without squeezing the text column into the pill-spill defect WS12
   documents. The glyph is untouched, so the visual weight of the skip icons is
   unchanged; only the transparent padding around them shrinks.
   Kept in ONE rule with the colour for the reason the quality-pill comment
   gives: a second block would split the styling for any reader or guard that
   scans from the first occurrence. */
.dvr-program-btn {
  /* ---- WS80 (owner, 2026-10-09): the skip glyph is the ACCENT, like its box ----
   * OWNER: "the skip button icons are a bit high and left adjusted."
   *
   * MEASURED first, because "high and left" had to be a measurement rather than
   * an impression. The ink's bbox clearance inside the 30x26 rectangle is
   * ALREADY symmetric -- 9.5px left / 9.5px right and 7.5px top / 7.5px bottom --
   * and its mass centroid is 0.51px off both axes. So the geometry was not the
   * defect. What was: the glyph rendered `rgb(110,110,115)` -- `--text-secondary`
   * -- against a `rgb(0,80,78)` accent rectangle. A grey glyph inside a green
   * box reads as sitting wrong inside it, because the box is the only thing
   * marking the shape and the glyph does not answer to it.
   *
   * The ±15 s pair -- which the owner asked the skip icons to MATCH in WS78 --
   * draws its glyph in `var(--accent)` (see `.dvr-step-btn { color: var(--accent) }`).
   * This is the skipped half of that match. */
  color: var(--accent);
  width: 36px;
  /* ---- WS78 (owner, 2026-10-09): a small rounded rectangle around the skip
   * glyphs, matching the ±15 s circles' accent, and CLICKABLE across the whole
   * rectangle. The rect is a pseudo-element centred on the button's 36x44 box
   * and SMALLER than it, so every point inside the rectangle is already inside
   * the button and therefore tappable (that is the "clickable for the
   * rectangular area too" the owner asked for).
   *
   * ---- WS81 (owner, 2026-10-09): 30x26 -> 36x36, the ±15 s ring's own size.
   * MEASURED BEFORE: the six marks in the transport were 30x26 (skip), 36
   * (steps) and 44 (play) -- three sizes for one row of controls, which is what
   * "it doesn't look perfect" was looking at. The four ARROW marks are now one
   * size: skip rect 36x36 and step ring 36x36 are pixel-identical frames, with
   * identical 20px glyphs inside them.
   *
   * The play button STAYS 44px. It is the primary action and deliberately the
   * largest thing in the row; shrinking it is a different change from
   * equalising the arrows, and it was not asked for. It is now the ONE
   * deliberate exception rather than one of three arbitrary sizes.
   *
   * A square rect with 9px corners is still a ROUNDED RECTANGLE (9px is well
   * under half the 36px side, so it is not a pill or a circle) -- the shape
   * language that distinguishes skip from step is preserved, only the size is
   * unified. 36x36 also keeps the rect inside the 36x44 button on both axes, so
   * the WS78 "every point inside the rect is clickable" property still holds. */
  position: relative;
}
.dvr-program-btn::before {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  width: 36px;
  height: 36px;
  margin: -18px 0 0 -18px;
  border: 1.5px solid var(--accent);
  border-radius: 9px;
  pointer-events: none;
}
/* WS80: the skip glyph drops 22px -> 20px, the size the ±15 s glyph already is
   and the size this pair was asked to match. At 22px the ink spanned 16.7px
   inside a 26px-tall rectangle (clearance 7.5px); at 20px it spans 16.7 * 20/24
   = 13.9px with 8.7px clearance, so the glyph-to-box relationship reads the same
   as the ±15 s glyph-to-circle one. Without an explicit size the button inherits
   `.player-btn svg { 22px }`, which is why the two pairs disagreed. */
.dvr-program-btn svg { position: relative; width: 20px; height: 20px; }
.dvr-program-btn[style*="display: none"] { display: none !important; }

/* ---- WS76 (owner, 2026-10-09): balance the ±15 s gap, and shift it off play ----
 * OWNER: "the +- 15 seconds button are feeling wise to close to the play button.
 * it also looks as they are closer to it than to the skip buttons ... move them
 * slightly towards the skip buttons."
 *
 * MEASURED on the five-button DVR row at 390px, BEFORE this: the gap is a flat
 * 12px, but the ±15 s button is 36px wide around a 20px glyph, so its glyph sits
 * 8 + 12 = 20px from the play circle's edge yet 8 + 12 + 8 = 28px from the skip
 * glyph's edge. That 8px asymmetry IS the owner's "closer to play than to the
 * skips", and it is what this corrects.
 *
 * The fix is a NET-ZERO margin pair on each ±15 s button: -4px on its INNER
 * edge (toward play) and +4px on its OUTER edge (toward the skip), so the button
 * moves 4px outward while the row's total width is byte-identical. The result at
 * 390px: ±15 s is 16px from the play circle and 16px from the skip glyph --
 * balanced, and 4px further from play.
 *
 * SCOPED TO `.player-controls.dvr`, i.e. a DVR channel only. A podcast has no
 * programme-skip buttons, so the class marks 17px - step 15 with nothing on the
 * other side: nudging there would SHIFT the whole transport off centre for no
 * reason, so the plain 3-button row keeps the plain uniform gap.
 *
 * Why margin and not a wider gap: the gap is symmetric, so raising it would move
 * the ±15 s button away from BOTH neighbours, which is the opposite of the
 * request. Only an asymmetric margin can move it toward one side and away from
 * the other. */
.player-controls.dvr .dvr-step-back { margin-left: 0px; margin-right: 0px; }
.player-controls.dvr .dvr-step-fwd { margin-left: 0px; margin-right: 0px; }

/* The DVR row now carries more controls — tighten the time label so the bar
   keeps most of the width. */
.dvr-row .player-time { min-width: 30px; }

/* Mode pill when behind live: accent tint + shows the clock time of the
   heard position (not color-only — the text changes from LIVE to HH:MM). */
.player-mode.behind {
  background: var(--accent-soft);
  color: var(--accent);
  font-variant-numeric: tabular-nums;
}

/* ---------- Fas 4: expanded player (redesign 2026-09-23) ---------- */
/* The panel grows UPWARD above the player: inserted as the player's first
   child, the bottom part (controls/seek row) never moves. Fold = swipe down
   on the panel or tap the chevron again. NO one-click meta expansion. */
.player-expand {
  max-height: 40dvh;
  height: auto; /* finger-following sets an explicit height during the drag */
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior: contain;
  padding: 0 4px 10px;
  border-bottom: 1px solid var(--border);
  margin-bottom: 8px;
  animation: expand-up 0.2s ease;
  transition: height 0.15s ease; /* smooth snap on commit/spring-back */
}
}
/* Grab zone at the panel top: owns the swipe-down-to-fold gesture so the
   scrollable content below is untouched (BUG 1 lesson). */
.expand-grab-zone {
  padding: 6px 0 2px;
  touch-action: none;
  cursor: grab;
}
@keyframes expand-up {
  from { opacity: 0; transform: translateY(12px); }
  to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  .player-expand { animation: none; }
}
.expand-content { display: flex; flex-direction: column; gap: 10px; }
.expand-row {
  display: flex;
  gap: 12px;
  align-items: flex-start;
}
.expand-img {
  /* ---- WS75 CORRECTION (owner, 2026-10-09): the EXTENDED player's cover is
   * the icon-sized one. It reads the SAME --icon the home channel/podcast icons
   * read, so the two are the same square by construction. 56px -> 77px at
   * 390px. This is the image the owner meant; the NORMAL player's cover stays
   * 44px (see --player-art). */
  width: var(--icon);
  height: var(--icon);
  border-radius: 8px;
  object-fit: cover;
  flex: none;
}
.expand-text { min-width: 0; }
.expand-label {
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--accent);
}
.expand-title { font-size: 14px; font-weight: 700; margin-top: 1px; }
.expand-sub { font-size: 12.5px; color: var(--text-secondary); margin-top: 2px; }
.expand-desc {
  font-size: 12.5px;
  color: var(--text-secondary);
  margin-top: 4px;
  line-height: 1.45;
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.expand-meta { font-size: 11.5px; color: var(--text-secondary); margin-top: 4px; font-variant-numeric: tabular-nums; }

/* Now-playing line (playlists/rightnow): ♪ artist – song. Hidden when
   song === null (talk content) — normal state, not an error. */
.now-playing-line {
  font-size: 12px;
  color: var(--text-secondary);
  margin-top: 3px;
  margin-left: var(--player-col);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  min-height: 0;
}
.now-playing-line:empty { display: none; }
.now-playing-line.has-song { color: var(--accent); }

/* ---- WS18: the song row, and the time state at its right-hand end ----
   The owner put the LIVE / "−N min" pill on the SONG row, rightmost, after
   WS16 had put it on the channel-name row. So `.now-playing-line` gains a
   sibling that shares its line.

   `.player-time-song` is the flex child that carries the song text. It exists
   as a wrapper rather than putting `margin-left: auto` straight on the pill
   because the pill must be pushed right by the SONG's width, and the song is
   the one that must be allowed to truncate first. `flex: 1` plus `min-width: 0`
   gives the song all the leftover space and lets the ellipsis do its job; the
   pill is `flex: none` so it can never be squeezed or wrapped.

   The row is `display: flex` at the FULL player's indent, so `.now-playing-
   line`'s own `margin-left: var(--player-col)` is zeroed HERE and applied to
   the row instead -- otherwise the song would be indented twice.

   The row carries no margin-top of its own: `.now-playing-line` keeps
   `margin-top: 3px` for the single-line podcast case, and the time row's own
   spacing comes from the pill's line box, so a talk channel with no song
   collapses to the pill alone rather than reserving an empty line. */
.player-time-row {
  display: flex;
  align-items: center;
  gap: var(--player-gap);
  margin-left: var(--player-col);
  min-width: 0;
}
/* ---- WS19: THIS RULE WAS MISSING, AND IT IS WHY THE PILL DISAPPEARED ----
   `app.js` builds `.player-time-row > .player-time-song > .now-playing-line`,
   but only a COMMENT ever described `.player-time-song`. With no rule, that
   div was a plain block flex item, so its `min-width` resolved to `auto` and
   it REFUSED to shrink below its text's intrinsic width. The song therefore
   kept its full 455px, and `margin-left: auto` on the pill pushed the pill out
   to right=577px on a 390px viewport -- 187px off-screen. Measured, not
   inferred.

   `min-width: 0` is the fix, and it must be on the WRAPPER, not on the line:
   the line is a block inside the wrapper, so the wrapper is what the flex
   algorithm asks to shrink. Giving the line `min-width: 0` alone changes
   nothing. */
.player-time-song {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
}
.player-time-row .now-playing-line {
  flex: 1 1 auto;
  min-width: 0;
  margin-left: 0;
  margin-top: 3px;
}
/* Compound selector on purpose -- see the note on the `.player-meta-row >
   .player-mode` rule above. A bare `.player-mode {` block earlier in the file
   would be matched first by a forward search and the real rule lost. */
.player-time-row > .player-mode {
  margin-left: auto;
  flex: none;
}

/* ---- WS19: the slow roll, for a song line too long to fit ----
   The owner asked for the iPhone lock-screen behaviour: a long artist+song
   rolls slowly instead of being cut off with an ellipsis.

   Two tracks, and which one applies is decided in JS, not by CSS guessing:
   `paintNowPlaying` compares the text's scrollWidth against the box and only
   adds `.rolling` when the text genuinely overflows. A short title must NOT
   drift, so the animation is attached to a class the app only sets when
   needed -- otherwise every short song would slide for no reason.

   The animation is on the INNER span, not on `.now-playing-line` itself: the
   line is the overflow box, so translating the box itself would move the box
   and expose the gap behind it. Translating an inner span slides the TEXT
   under a stationary window, which is what a lock screen does.

   Duration scales with length via `--roll-dur`, set inline per paint, so a
   barely-overflowing title crawls and a very long one still finishes. The
   `8%`-`92%` keyframe range is deliberate: it scrolls far enough to reveal
   the tail, and the pause at each end gives the eye a moment before it
   repeats. `prefers-reduced-motion` disables it outright, because continuous
   motion is a genuine accessibility problem, not just a preference. */
.player-time-song .now-playing-line { display: block; }
.player-time-song .now-playing-line > .roll-track {
  display: inline-block;
  white-space: nowrap;
  will-change: transform;
}
/* ---- WS21: the "..." was the ELLIPSIS, not a missing string ----
   The owner saw `♪ ...` on P2 Nottturno and the DOM held all 143 characters:
   "Christian Ihle Hadland (piano), Trondheim Symphony Orchestra, ... –
   Piano Concerto no 26 in D major, K.537 'Coronation'". The three dots were
   the line-level `text-overflow: ellipsis` painting, and the rolling track was
   sliding BEHIND it.

   Measured on the live origin:
     line.clientWidth   252
     track.scrollWidth  775   (+ 21 for the note = 796 of inline content)
     line.scrollWidth   785  -> exceeds the window, so the ellipsis fires

   `.now-playing-line` is a block with `overflow: hidden` and
   `text-overflow: ellipsis`. That is right for a STATIC label -- it is how a
   short title that does not fit degrades. It is wrong for a rolling one: the
   track is translated by transform, and the line's own inline content never
   shrinks, so the ellipsis is permanent and rides over the animation for the
   whole cycle. The owner saw the note and the dots and concluded the text was
   missing; it was there the entire time, behind a decoration.

   So the ellipsis is switched OFF whenever the line is actually rolling. A
   title that does NOT overflow keeps it, which is the case it was written for
   and the only case where it adds information. `clip` does the same job as
   `hidden` here; the line already clips, so nothing is lost.
   ---------------------------------------------------------------------------
   `.player-time-song .now-playing-line.rolling { text-overflow: clip; }` */
.player-time-song .now-playing-line.rolling {
  text-overflow: clip;
}
/* ---- WS20: the roll was BROKEN and the owner's screenshot proves it ----
   The owner on P2 Nottturno saw `♪ ...` -- the note, and nothing else. The
   cause is a unit error in the keyframe, reproduced exactly:

     to { transform: translateX(calc(-100% + var(--roll-box))); }

   Inside `translateX`, a percentage resolves against the ELEMENT's own border
   box, so `-100%` is the TRACK's width. But `--roll-box` was stored as
   `line.clientWidth / track.scrollWidth * 100` -- a ratio of the WINDOW to the
   track -- so the two terms had different bases. Sampled at 50% and 100% of the
   animation on a short title ("A"), the computed transform was
   `matrix(1,0,0,1,122,0)` and then `+244px`: a POSITIVE translation, sliding
   the text to the RIGHT out of the clipped window and leaving the note alone.

   The roll only triggers on a real overflow, so how did a short title get one?
   It did not have to: `.rolling` is set by a rAF and cleared by the next paint,
   and a resize, a song change, or a re-render can all leave the class on a
   title that no longer overflows. The value was then nonsense in both
   directions -- negative-but-wrong for a long title (it stopped exactly the
   note's width short of revealing the tail) and catastrophically positive for a
   short one.

   The fix removes the unit ambiguity instead of correcting the ratio: JS
   measures the real pixel distance to travel and stores it as a plain length
   in `--roll-shift`, and the keyframe is a single unambiguous term. There is no
   percentage left to resolve against the wrong basis.

   The distance is the track's width minus the width actually available to it
   (the window minus the note's width), so the tail is revealed exactly, and it
   is clamped at >= 0px: a title that fits must never slide. */
.player-time-song .now-playing-line.rolling > .roll-track {
  animation: sr-song-roll var(--roll-dur, 14s) ease-in-out infinite alternate;
}
@keyframes sr-song-roll {
  from { transform: translateX(0); }
  to { transform: translateX(calc(-1 * var(--roll-shift, 0px))); }
}
@media (prefers-reduced-motion: reduce) {
  .player-time-song .now-playing-line.rolling > .roll-track {
    animation: none;
  }
}
/* The indent above is for the FULL player, where the song line sits on its own
   row under the artwork. The minimised mini-bar builds a compact text stack of
   its own (40px thumb, 10px gap) whose title and programme are NOT indented, so
   the song line there must not be either — otherwise it would sit 56px in
   while the two lines above it sit at 0. Scoped by .player-mini, not by a
   second literal offset, so the two layouts cannot drift apart. */
.player-mini .now-playing-line { margin-left: 0; }

/* Song artwork in the expanded player (iTunes Search, verified from
   GitHub Pages). Placeholder when artwork is missing/unavailable. */
/* The song's album cover in the extended player: the same icon-sized square as
   the programme image above, so a song and a talk programme show the same-sized
   cover. WS75 correction (owner, 2026-10-09). */
.expand-img-song { width: var(--icon); height: var(--icon); border-radius: 10px; }
.expand-img-placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--border);
  color: var(--text-secondary);
  font-size: 26px;
}

/* Chevron handle: rotates when open (points up when folded, down when open). */
.player-expand-btn { transition: transform 0.2s ease; }
.player-expand-btn.open { transform: rotate(180deg); }
@media (prefers-reduced-motion: reduce) {
  .player-expand-btn { transition: none; }
}

/* ---------- Fas 5: context cards (long-press) ---------- */
.context-card { padding-bottom: calc(var(--safe-bottom) + 12px); }
.card-head {
  display: flex;
  align-items: center;
  gap: 12px;
  min-width: 0;
  flex: 1;
}
.card-img {
  width: 44px;
  height: 44px;
  border-radius: 10px;
  object-fit: cover;
  flex: none;
}
.card-head-text { min-width: 0; }
.card-sub { font-size: 12.5px; color: var(--text-secondary); margin-top: 1px; }
/* WS54: the owner asked for the gap between the channel header and the "Igår"
 * label to be ZERO pixels. MEASURED: header bottom 176, card-body top 184 --
 * an 8 px gap that is exactly this `margin-top`, nothing to do with the day
 * label's own padding. The margin existed to separate the body from the header
 * when the header was in normal flow; now that the header is sticky and the
 * labels position themselves, the margin is pure gap. Removed.
 *
 * `--card-label-h` is ALSO no longer needed: with a single label per list in the
 * podcast cards, and the tablå card's own depth numbering, the sum below is
 * driven by depth only. */
.card-body { margin-top: 0; }
.card-loading {
  padding: 24px 0;
  text-align: center;
  color: var(--text-secondary);
  font-size: 13.5px;
}
.card-list { display: flex; flex-direction: column; }

/* ---- WS52: the tablå card scrolls like a fixed sheet, not a free list. ----
 *
 * THE OWNER'S TWO SCREENSHOTS showed two different symptoms that looked like one
 * flaky thing:
 *   1. the P2 icon visible at rest, GONE after a short scroll
 *   2. "Igår" sliding under the P2 header instead of stopping below it
 *
 * MEASURED causes, both in this file:
 *
 *   1. `.sheet-header` and `.sheet-grab-zone` were `position: static`. The
 *      .sheet is the scroll container, so the header is ordinary content: any
 *      scroll moves it off the top and the channel icon leaves the screen. The
 *      sticky DAY LABEL was pinned to `top: 0`, i.e. to the scroll box, so it
 *      slid underneath the header rather than below it.
 *
 * The fix is to declare the policy that was never written down: the sheet's
 * grab zone + header are FIXED, the day labels stick DIRECTLY BENEATH them, and
 * only the programme rows scroll. "Igår" and "Idag" therefore stay in stable
 * positions under the channel, exactly as the owner asked, and never overlap it.
 *
 * `--card-fixed-h` is the measured height of grab zone + header. It is declared
 * here rather than computed in JS because it must match the CSS that creates
 * it; a JS measurement would be a second source of truth that can drift.
 */
.sheet.context-card {
  --card-fixed-h: 0px;
  padding-top: 0;
}

/* ---- WS60: the tablå card's rows must clear the FIXED bands.
 *
 * OWNER: "the gesture down on the cards shown after long press has an
 * implemented problem in the same area ... we need to fix so that they are
 * clickable even if gesture down is still in the same place", then, precisely:
 * "with buttons i mean also the now non clickable radio shows and podcasts as
 * well as Info and Save buttons ... gestures areas should never stop the card
 * button press functions from working".
 *
 * MEASURED, and this is a REAL pre-existing defect that enlarging the band made
 * worse rather than caused. The card's two sticky bands occupy a fixed block at
 * the top of the scroll box:
 *
 *   band   y=76  h=44   (was 22 — A1b)
 *   header y=120 h=51
 *   -> the fixed block covers y=76..171, i.e. the first 171px of the list
 *
 * `.card-list` has NO top padding, so the first rows start at the very top of
 * the scroll box and slide UP underneath that block. MEASURED across seven
 * scroll positions: **4 rows were FULLY hidden** at every one of them (rows at
 * y=-17, 27, 71, 115 — all 44px tall, all entirely behind the bands). They were
 * not merely hard to tap; they were untappable and invisible at rest.
 *
 * `--card-fixed-h` is the MEASURED height of band + header (published by
 * openContextCard on open and on resize, so it cannot drift from the CSS). Using
 * it here means the clearance is derived from the bands that actually exist
 * rather than a second hard-coded number that would silently desync the moment
 * either band changes height — which is precisely what happened when the band
 * grew from 22px to 44px in this same pass.
 *
 * `scroll-padding-top` is the matching half: it makes `scrollIntoView` and
 * anchor navigation land BELOW the bands instead of behind them, so a row the
 * app scrolls to programmatically is not hidden either.
 */
/* ---- WS62: this padding became REDUNDANT, and was causing a 95px black hole.
 *
 * WS60 added `padding-top: var(--card-fixed-h)` for a good reason, and the
 * reason was real: back then `.sheet-grab-zone` was `position: absolute`, so
 * the band was OUT OF FLOW and the first rows genuinely slid underneath it.
 * MEASURED then: 4 rows fully hidden at every scroll position.
 *
 * The same WS60 pass also changed the band to `position: sticky` (see the
 * generic `.sheet-grab-zone` rule) so the gesture stays reachable when the list
 * is scrolled. A sticky element is IN FLOW: it reserves its own 44px and the
 * header its 51px. So the rows already start below them.
 *
 * The padding was never removed. It therefore counted the same 95px TWICE:
 *
 *   MEASURED (WS62), radio card, scrollTop 0:
 *     band    y=76  h=44   -> bottom 120
 *     header  y=120 h=51   -> bottom 171
 *     padding-top on .card-list = 95px   <-- the SAME 95px, again
 *     first row  y=302               <-- 131px of black below the header
 *
 * With the padding removed the same measurement gives first row y=207, i.e. 36px
 * below the header -- which is `.card-body`'s own margin, not a hole.
 *
 * REMOVING IT IS SAFE, and that was measured rather than assumed:
 *   rows fully hidden behind the bands at rest .... 0
 *   rows fully hidden mid-scroll .................. 0
 *   rows unreachable at ANY scroll position ...... 0 of 41 (radio), 0 of 10
 *
 * So the property WS60's guard was protecting still holds without the constant.
 * The guard is restated to that property in tests/itunes-podcast.test.mjs.
 *
 * `scroll-padding-top` STAYS: it is unrelated to layout (it only affects
 * `scrollIntoView` and anchor jumps) and it is still needed for the same
 * reason -- a row scrolled to programmatically must land below the bands.
 */
.sheet.context-card {
  /* Anchor scrolling must clear the fixed block. */
  scroll-padding-top: var(--card-fixed-h, 0px);
  /* ---- WS62, part 2: THE SHORT-LIST REGRESSION, and its honest cause. ----
   *
   * Deleting the list padding removed a 95px BLACK BAND from every card (see
   * the note above), but it also removed 95px of SHEET HEIGHT, and that turned
   * out to matter: `.sheet` is bottom-anchored and CONTENT-SIZED
   * (`max-height`, no `height`), so a card with few rows got shorter and its top
   * edge -- band, header and all -- slid DOWN the screen.
   *
   * MEASURED, podcast card, before vs after:
   *                    sheetTop   sheetH   firstRow
   *   with padding        76        637       272      <- the black hole
   *   without padding    152        561       253      <- no hole, but FLOATING
   *
   * So the padding was doing two jobs at once, one of them (clearing the bands)
   * obsolete and the other (holding the card open) still real. Removing it
   * outright fixed the first and broke the second.
   *
   * The fix gives the height back where it belongs -- on the SHEET, as a floor
   * sized from the same published variable -- rather than as padding that
   * re-creates the hole. A podcast with 3 episodes and one with 60 now both open
   * at the same height, and the row still starts immediately under the header.
   */
  min-height: calc(100dvh - var(--band-y));
}

/* ---- WS58: the Info page IS the settings sheet. There is deliberately NO
 * `.sheet.info-sheet` override.
 *
 * MEASURED, and this is why the override that used to be here was deleted. With
 * `padding-top: 0` — copied from `.sheet.context-card`, which was the wrong
 * precedent — the Info page's header rendered at y=76 while the cog-wheel
 * page's rendered at y=110. Same band (y=76, 22px), same pill, same radius,
 * same everything else, and a 34px difference in the one row a reader actually
 * looks at. Two pages that agree on every number except the one that matters
 * is the same defect as "equal height mistaken for equal position", which this
 * log records twice already.
 *
 * `.sheet.context-card` needs its own padding because the tablå card has a
 * STICKY-HEADER contract of its own (--card-grab-h / --card-fixed-h, measured
 * on open). The Info page has no sticky anything: one band, one header, one
 * scrolling body, all of which it now shares with the settings sheet verbatim.
 *
 * So the class name stays on the element — it is the hook that says "this is
 * the Info page" and it is what the test guards — but it carries NO
 * declarations. If a future session needs one, that is the signal the two
 * pages have started diverging again, and the override must be justified by a
 * measurement on this page rather than copied from the tablå card.
 */
.sheet.context-card > .sheet-grab-zone,
.sheet.context-card > .sheet-header,
.sheet.context-card > .card-body > .card-list > .card-day-label {
  position: sticky;
  /* Stacked offsets: the grab zone sticks at the very top, the header directly
     beneath it. Both are opaque, so a label can never show through either. */
  z-index: 3;
  background: var(--bg);
}
.sheet.context-card > .sheet-grab-zone { top: 0; }
.sheet.context-card > .sheet-header { top: var(--card-grab-h, 33px); }

/* The header carries the channel icon and the close button. Opaque background
   + a bottom rule, so rows scrolling underneath read as "underneath" instead of
   colliding with the text. */
.sheet.context-card > .sheet-header {
  padding-top: 4px;
  box-shadow: 0 1px 0 var(--border);
}

/* ---- WS55: the WHOLE top band is a drag surface, not just the mark. ----
 *
 * The owner: "the band to touch on for the dragging down doesn't seem to cover
 * the whole top area that is possible to use for the dragging down. it feels
 * like one has to be very close to the position of the horizontal mark."
 *
 * MEASURED, in the driven DOM, on the tablå card (492x760):
 *
 *   band                 y-range   height  touch-action    app owns it?
 *   -------------------  --------  ------  -------------  -----------
 *   .sheet-grab-zone     91 - 124    33px  none           YES
 *   .sheet-header       124 - 175    51px  manipulation   no
 *   .card-day-label Igår 175 - 205   30px  manipulation   no
 *   .card-day-label Idag 205 - 235   30px  manipulation   no
 *
 * So of the 144 px a finger sees at the top of the card, only 33 px -- the strip
 * holding the 5 px mark -- was actually guaranteed to reach the app's gesture
 * code. That is the owner's "one has to be very close to the mark", stated as a
 * number: the reliable target was 33 px tall and centred on a 5 px line.
 *
 * The cause is not the JS binding. WS54 already bound enableSwipeToClose to
 * BOTH the grab zone and the header, and a synthetic drag from y=145 (inside the
 * header) really does close the card. The cause is `touch-action`.
 *
 * `.sheet-grab-zone` sets `touch-action: none`, which tells the browser "the app
 * handles vertical gestures here, do not scroll". The header never got that
 * declaration, so it computed to `manipulation` -- the UA is free to claim a
 * drag as a scroll. On desktop Chromium that never matters; on the owner's
 * iPhone Safari claims it and the sheet does not move. A headless run can
 * therefore show the header swipe "working" while the device still refuses it,
 * which is exactly the trap this repo keeps re-learning.
 *
 * The fix is to make the CSS authorise what the JS already binds, for the whole
 * fixed band: the grab zone, the header, AND the day labels that stick directly
 * beneath them. Every one of those is a FIXED, non-scrolling band -- the only
 * ones in the card -- so giving them `touch-action: none` costs nothing and
 * cannot fight the programme list, which keeps `manipulation` and stays
 * scrollable.
 *
 * Deliberately NOT done: `touch-action: none` is not applied to the sheet or to
 * `.card-list`. The list is the scroller. Taking its gestures would restore the
 * very defect (WS50/WS51) where every scroll touch also ran the drag logic.
 */
.sheet.context-card > .sheet-grab-zone,
.sheet.context-card > .sheet-header,
.sheet.context-card > .card-body > .card-list > .card-day-label {
  touch-action: none;
}

/* ---- WS63: `touch-action` is NOT INHERITED, and that is why the banner did
 * not close on the owner's iPhone while every desktop test passed.
 *
 * OWNER, 2026-10-04: "for radio channels selecting the banner and drag down
 * must close the sheet."
 *
 * The rule above sets `touch-action: none` on the grab zone and the header, and
 * that is what the WS55 note says makes the binding reliable on iOS. It is also
 * what made a Chromium test of the banner drag PASS -- so the desktop harness
 * approved this twice while the device refused it.
 *
 * `touch-action` applies to the element the TOUCH STARTS ON, and it does NOT
 * inherit. So the band saying `none` only covers the band's own few pixels. Any
 * touch that lands on a CHILD is judged by that child's own value.
 *
 * MEASURED, in the driven DOM, sampling every 4px down the banner on the radio
 * card -- the topmost element at each y and its COMPUTED touch-action:
 *
 *     y=78..82   sheet-grab-zone    none              <- covered
 *     y=86..90   sheet-grab         MANIPULATION      <- the visible pill
 *     y=94..118  sheet-grab-zone    none              <- covered
 *     y=126..170 card-head          MANIPULATION      <- logo + title + X
 *
 * The pill is 5px tall and the title row is 45px tall: **50 of the ~96px the
 * owner sees as "the banner" computed to `manipulation`**, which is the UA's
 * default "I may claim this as a scroll". On desktop Chromium the UA never
 * bothers. On iOS Safari it does, and the sheet does not move.
 *
 * This is the SAME defect the WS55 note describes for the header, one level
 * deeper: the fix there was correct but incomplete, because it named the band
 * and not what sits on it.
 *
 * THE FIX: cover every descendant of the bands, not the bands alone. The
 * selector is scoped to `.context-card` and to the bands' own subtrees, so it
 * cannot reach `.card-list` or any row -- the list stays `manipulation` and
 * stays scrollable, which is WS61's priority-1 requirement.
 *
 * `pointer-events` is left alone: the close button must stay tappable, and this
 * changes only how the BROWSER arbitrates a drag, never whether a tap lands.
 */
.sheet.context-card > .sheet-grab-zone *,
.sheet.context-card > .sheet-header * {
  touch-action: none;
}

/* WS54: the GAP under the header.
 *
 * The owner: "the igår and idag at scrolling has a gap that should not be between
 * the P1 section attached and the label igår … needs to be tightened to zero
 * pixels."
 *
 * MEASURED: header bottom 142.8, first day label top 176.2 -- a 33 px gap, and
 * 33 px is EXACTLY `.sheet-grab-zone`'s height. The cause is the WS52 offsets:
 * the day labels stick at `top: var(--card-fixed-h)`, and `--card-fixed-h` is
 * grabZone + header (84 px). The grab zone is sticky ABOVE the header, so that
 * sum double-counts it -- the labels were told to clear 84 px when only 51 px
 * was actually occupied. The 33 px zone is paid for twice.
 *
 * The fix is two measured heights instead of one sum:
 *   --card-grab-h    the grab zone alone  -> the header's sticky offset
 *   --card-fixed-h   the header alone     -> the day labels' sticky offset
 *
 * So the labels sit directly against the header (gap 0) and the zone keeps the
 * mark visible without stealing a row of space.

/* The day labels stick UNDER the fixed header block. `--card-fixed-h` is set
   from the real measured height by the one place that builds the card.

   WS52, second defect found while VERIFYING the first fix: "Igår" and "Idag"
   live in the SAME .card-list (measured: both parentElement .card-list, 49
   children, Idag at index 29). Two sticky siblings in one scroll container
   therefore BOTH park at the same offset once the second is reached -- at
   scrollTop 1400+ they sat on top of each other at the same pixel, so the day
   you were reading silently changed from "Idag" to whichever label won.

   The fix is to STACK them: each label sticks directly beneath the one above
   it, which is what makes the owner's policy -- "Igår and Idag move into their
   positions under the channel" -- true rather than aspirational.

   The depth is an INLINE custom property written where the label is created,
   not a CSS counter. A counter cannot work here: `counter()` on an element and
   `counter-increment` on its own ::after are evaluated at layout time, but the
   value each sticky element needs is its ORDINAL POSITION AMONG THE LABELS, and
   the label is created by JS in a loop whose count is known exactly there. One
   source of truth, and it is the one that already has the answer. */
.card-day-label {
  position: sticky;
  /* WS54. An earlier attempt here subtracted the label's own padding from this
   * offset. That was measured against the WRONG element: the visible gap is
   * `.card-body`'s `margin-top`, not the label's padding, so the subtraction
   * bought nothing and then made the label overlap the header by 41 px once
   * scrolled. Corrected below -- the offset is a plain sum again.
   *
   * `--card-label-h` is the label's measured height, used so the stack does not
   * drift if the type scale or padding changes. */
  --card-label-pad-t: 8px;
  --card-label-h: 30px;
  top: calc(var(--card-fixed-h) + var(--card-label-depth, 0) * var(--card-label-h));
  /* Depth 0 is the header-adjacent label and must paint OVER the ones below
     it, or a deeper label scrolling up would cover the one already parked. */
  z-index: calc(3 - var(--card-label-depth, 0));
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-secondary);
  background: var(--bg);
  /* padding-top MUST equal --card-label-pad-t above. Kept adjacent so a change
     to one is obviously a change to the other. */
  padding: var(--card-label-pad-t) 4px 6px;
  border-bottom: 1px solid var(--border);
}
.card-row {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 10px 4px;
  border: none;
  border-bottom: 1px solid var(--border);
  background: transparent;
  color: var(--text);
  text-align: left;
  cursor: pointer;
  min-height: 44px;
  font-size: 13.5px;
}
.card-row:last-child { border-bottom: none; }
.card-row:disabled { cursor: default; opacity: 0.55; }

/* ---------- WS67: the podcast-freshness card ----------
 *
 * Deliberately built on the SAME measurements as `.card-row` (44px min target,
 * 13.5px text, 10px/4px padding, 1px divider). The owner asked for "a card with
 * our standard design", and a near-copy that reads as a near-copy is worse than
 * reusing the row that already looks right: reusing it means a future change to
 * the app's row styling reaches this card too, instead of leaving it behind as
 * the one screen that no longer matches.
 */
.fresh-list { display: flex; flex-direction: column; }

.fresh-row {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 10px 4px;
  min-height: 44px;
  border: none;
  border-bottom: 1px solid var(--border);
  background: transparent;
  color: var(--text);
  text-align: left;
  cursor: pointer;
  font: inherit;
  font-size: 13.5px;
}
.fresh-row:last-child { border-bottom: none; }
.fresh-row:active { background: rgba(127, 127, 127, 0.15); }

.fresh-img {
  width: 34px;
  height: 34px;
  flex: none;
  border-radius: 6px;
  object-fit: cover;
  background: var(--accent-soft);
}

.fresh-text { min-width: 0; flex: 1; }

.fresh-name {
  font-weight: 600;
  overflow-wrap: anywhere;
}

/* The status line. `fresh-date` is italic for the same reason `.pick-src-date`
 * is: the owner asked for a date to read differently from a label. */
.fresh-when {
  margin-top: 2px;
  font-size: 12px;
  color: var(--text-secondary);
  opacity: 0.85;
}
.fresh-date { font-style: italic; }

/* The freshest row is called out, because the owner's stated purpose is "the
 * latest updated podcast in the favorites list is shown on top". A green left
 * edge marks it without moving anything, so the list order still carries the
 * meaning. */
.fresh-row.is-newest .fresh-name { color: var(--accent); }
.fresh-row.is-newest { box-shadow: inset 3px 0 0 var(--accent); }
/* Whole row is the play target (user request 2026-09-23): the ▶ glyph is
   decoration — the row itself must read as a button. Active-state feedback
   makes the full-row hit area visible on tap. */
.card-row:not(:disabled):active { background: rgba(127, 127, 127, 0.15); }
.card-row.no-audio { color: var(--text-secondary); }
.card-row.ongoing .card-title { color: var(--accent); font-weight: 600; }
.card-time {
  font-variant-numeric: tabular-nums;
  font-size: 12px;
  color: var(--text-secondary);
  flex: none;
  min-width: 40px;
}
.card-title {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.card-state {
  flex: none;
  color: var(--accent);
  font-size: 12px;
  min-width: 20px;
  text-align: right;
}
.card-row.no-audio .card-state { color: var(--text-secondary); }

/* ---------- skeletons ---------- */
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } }
.skel {
  border-radius: var(--radius-sm);
  background: var(--border);
  animation: pulse 1.4s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
  .skel { animation: none; opacity: 0.6; }
  * { transition: none !important; }
}
.skel-chan { height: 72px; border-radius: var(--radius); }
.skel-icon { aspect-ratio: 1; max-width: 96px; flex: 1; border-radius: 18px; }
.skel-news { height: 88px; border-radius: var(--radius); margin-bottom: 10px; }

/* ---------- states ---------- */
.state-msg {
  padding: 28px 8px;
  text-align: center;
  font-size: 14.5px;
  color: var(--text-secondary);
}

/* The podcast list is search-gated (WS44c): 371 SR programmes rendered 33 000px
   tall, so the unsearched state is a prompt rather than 89 screens of scroll.
   The count is shown on purpose -- it tells the owner the list is not empty,
   it is just further down a search box. */
.pick-empty {
  padding: 30px 20px;
  text-align: center;
}
.pick-empty-title {
  font-size: 15px;
  font-weight: 600;
  color: var(--text-primary);
  margin-bottom: 6px;
}
.pick-empty-sub {
  font-size: 13.5px;
  line-height: 1.45;
  color: var(--text-secondary);
}

.retry-btn {
  display: inline-block;
  margin-top: 12px;
  padding: 10px 22px;
  min-height: 44px;
  border-radius: 999px;
  border: none;
  background: var(--accent);
  color: #fff;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}

.empty-slot {
  border: 1.5px dashed var(--border);
  border-radius: var(--radius);
  min-height: 72px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text-secondary);
  font-size: 13px;
}

/* ---------- bottom sheet (edit / setup) ---------- */
.sheet-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.4);
  z-index: 40;
  display: flex;
  align-items: flex-end;
  justify-content: center;
}

.sheet {
  background: var(--bg);
  /* ITEM 1, FOURTH PASS -- ALIGNMENT, and this is the owner's own definition:
     "sleak look means start in the same y-position where 'KAnaler' is not
     seen".
   *
   * MEASURED, before: the settings band was at y=92 and the Info band at
   * y=0. Both were 22 px tall -- equal in SIZE, 92 px apart in PLACE. Equal
   * padding and equal height had been treated as alignment, and they are
   * not: a bottom-anchored sheet puts its top edge 92 px down, so its band
   * can never line up with a full-height page's band no matter how the
   * shared numbers are tuned.
   *
   * THE OWNER CORRECTED THIS ONCE ALREADY. Putting the band at the top of the
   * screen (y=0) made the two bands agree with each other and disagreed with
   * the thing the instruction actually names: the "Kanaler" heading, measured
   * at y=76 on the main screen. Both bands now start at `--band-y`, so they are
   * aligned with each other AND cover the same word the owner pointed at.
   *
   * This is the second time a shared NUMBER was mistaken for a shared
   * POSITION. The first was matching the pill's padding; the second was
   * matching the band's height. Neither could ever have failed, because both
   * compare a measurement against a target the implementation itself chose.
   */
  /* ITEM 1, fourth pass: the positioning context for the drag band, whose
     `top` must resolve against the SHEET rather than the viewport. Folded
     into the existing rule instead of declared separately, because WS55's guard
     requires exactly ONE `.sheet` rule -- a second one makes that helper return
     null and silently disarms the `overflow-y: auto` counterweight check. */
  position: relative;
  width: 100%;
  max-width: 480px;
  border-radius: 20px 20px 0 0;
  /* ITEM 1, FOURTH PASS (correction): the band sits at `--band-y`, so the
     sheet's own TOP EDGE must sit there too -- otherwise the sheet paints a
     76px white block above the band and the "sleak look" is lost. The overlay
     is bottom-anchored (`align-items: flex-end`), so capping the height at
     `100dvh - band-y` lands the top edge exactly on the band.
     The old `padding-top: 12px` put the header ABOVE the band, which is why the
     pill read as stranded at the top of the screen. 22px (the band) + 12px
     (the old gap) = the 34px of clearance below it. */
  max-height: calc(100dvh - var(--band-y));
  padding: 34px 20px calc(var(--safe-bottom) + 20px);
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior: contain; /* keep scroll inside the sheet — no bleed-through to the page behind */
}

/* ---- ITEM 1 (2026-10-03): the settings sheet's whole top band swipes. ----
 *
 * The owner, from a screenshot: "the top bar for Info och anpassningar is to
 * small. make it possible to swipe down the page from to top of now and further
 * down on the page."
 *
 * MEASURED: `.sheet-grab-zone` is 33 px and already had `touch-action: none`.
 * `.sheet-header` (32 px) and `.sheet-actions` (44 px) -- the row holding the
 * Tests and Spara buttons -- computed to `manipulation`, so the UA could claim a
 * drag there as a scroll. 33 px was the entire reliable target.
 *
 * `touch-action: none` on the two missing bands is what makes the binding
 * reliable on iOS; the JS binding alone is not enough, and neither is this
 * declaration alone. Both are applied, exactly as WS55 did for the context card.
 *
 * The news slider and the favourites list are deliberately NOT included: they
 * scroll, and taking their gestures would restore the WS50/WS51 defect where
 * every scroll touch also ran the drag logic.
 */
.sheet-grab-zone,
.sheet-header,
.sheet-actions {
  touch-action: none;
}

/* ---- THE DRAG SURFACE. Read this before changing anything below it. ----
 *
 * THIRD PASS, and it FAILED. `min-height: 50dvh` was applied to this band and
 * measured 384px on a 768px viewport -- exactly half the screen, which is what
 * I had asked for and nothing like what the owner had asked for. The band was a
 * SPACER, so it pushed "Info och anpassningar" from the top of the sheet down
 * to y=488, leaving a large empty white panel above it. The owner's screenshot
 * showed exactly that, on both the settings sheet and the Info page.
 *
 * WHAT WENT WRONG IN THE THINKING, since it is the reusable part: I measured
 * the thing my own implementation produced (384px = half of 768px) and called
 * it a pass. The prompt was not a number. It was a LAYOUT: "the position of
 * both bands should be in the same position and move up so they cover the word
 * Kanaler for a sleek look". A correct number in the wrong place is still wrong,
 * and a measurement that only compares against my own target cannot see that.
 *
 * THE FIX, and it is structural rather than a smaller number. The drag area is
 * now an ABSOLUTELY POSITIONED OVERLAY that sits UNDER the sheet's own content
 * and is tall, while the content itself is lifted above it and starts at the
 * top as it always did. So:
 *   - the pill is at the top of the sheet, where it has always been;
 *   - "Info och anpassningar" is at the top, not pushed down;
 *   - the tappable region extends down BEHIND the content, which is where a
 *     thumb naturally lands when the sheet is tall.
 *
 * `.sheet` becomes the positioning context (`position: relative`) so the
 * overlay is measured against the SHEET, not the viewport -- otherwise the
 * 50dvh would be measured from the page top and would float over the wrong
 * region entirely.
 */
/* The tall, invisible drag target. It renders BENEATH the sheet's content, so
   it never pushes anything down and never covers a control. */
.sheet-grab-zone {
  /* ---- A1b (owner, 2026-10-04), part 1 of 2: the band must STAY REACHABLE.
   *
   * OWNER: "on both cards the gesture down to close the card should be further
   * down on the screen than now ... this to make it easy to close the cards."
   *
   * MEASURED, and the cause was NOT the size of the drag area — it was that the
   * band scrolled away:
   *
   *   scrollTop 0    -> band y=76    (reachable)
   *   scrollTop 600  -> band y=-524  (OFF-SCREEN, `bandVisible: false`)
   *
   * The band was `position: absolute` inside `.sheet`, which is the SCROLL
   * CONTAINER (`overflow-y: auto`). An absolutely positioned child of a scroll
   * container scrolls away with the content, so the taller the page the further
   * off-screen the close gesture went. On the Info page (scrollHeight 1865 vs
   * clientHeight 637) one flick of the wheel put the band 600px above the
   * viewport and the sheet could not be closed by gesture at all. Driven proof:
   * unscrolled flick closed 1/1, scrolled flick closed 0/1 with the band
   * reported `offScreen: true`.
   *
   * `position: sticky` is the fix, and it is the value
   * `.sheet.context-card > .sheet-grab-zone` has always used — so this makes the
   * surfaces agree on the one property that decides whether the gesture is
   * reachable at all. The tablå card was already correct here and the settings
   * and Info sheets were not.
   */
  position: sticky;
  top: 0;
  left: 0;
  right: 0;
  /* ---- A1b, part 2: the band is TALLER, because the owner asked for it and
   * because 22px is below the 44px iOS guidance this log already records.
   *
   * The pill is centred in the strip, so it grows downward with the band and
   * stays where the owner's screenshots show it — at the sheet's top edge.
   *
   * WHY NOT "half the vertical app size", which is what was asked for: see the
   * note on the removed `::after` below. A half-screen drag region and "the
   * gesture must never stop a button working" are MUTUALLY EXCLUSIVE while the
   * region is an overlay that captures touches. The owner's second, explicit
   * instruction wins, and the band is enlarged within what can be enlarged
   * without capturing anything.
   */
  height: 44px;
  min-height: 0;
  padding: 10px 0 0;
  touch-action: none;
  cursor: grab;
  /* Below the sheet's own content. `z-index: 0` with the children lifted to 1
     is what makes this a layer UNDERNEATH rather than one painted over the
     controls — an overlay that covered them would swallow every tap, which is
     the one outcome guaranteed to be worse than the defect it replaces. */
  z-index: 0;
  pointer-events: auto;
}
/* Everything that is NOT the drag surface sits above it. Two selectors rather
   than one because the sheet's children are heterogeneous (header, actions,
   sections, list), and a blanket rule is how a decorative element accidentally
   starts eating taps. */
.sheet > *:not(.sheet-grab-zone) {
  position: relative;
  z-index: 1;
}
/* The tall drag target for the sheet, matching the Info page's `::after`
   approach: the element above is a small strip, this is the large tappable area
   behind the content. Split this way the strip can be 22px -- the same as the
   other surface -- while the drag region stays half the sheet tall. Doing it
   with `height: 50%` on the strip itself is what produced the 384px void the
   owner screenshotted. */
/* ---- A1b, RESOLVED: the tall drag overlay is DELETED, and the owner's two
 * instructions are what settled it.
 *
 * The owner asked for two things across two messages, and on this codebase they
 * are MUTUALLY EXCLUSIVE while the drag region is an overlay that captures
 * touches:
 *
 *   1. "the gesture down to close the card should be further down on the screen
 *       than now. preferably from half the vertical app size"
 *   2. "gestures areas should never stop the card button press functions from
 *       working" — naming the channel rows, the podcast rows, and the Info and
 *       Save buttons
 *
 * An overlay only captures touches while it is ON TOP of the controls, and it
 * only stays out of their way while it is BEHIND them. No arrangement both
 * swallows half the screen and lets every control through. The second
 * instruction is the later, stricter one, and the first is still satisfiable
 * without any overlay — so the overlay goes.
 *
 * MEASURED, so the removal is not a guess:
 *   - `::after` computed to **200px tall**, not the "50%" in the source.
 *     `height: 50%` on an absolutely positioned pseudo-element resolves against
 *     the CONTAINING BLOCK — the band — so it computed to 11px and was floored by
 *     `min-height: 200px`. **The "half the screen" in the source did nothing.**
 *   - it painted OVER the controls it was meant to sit behind. On the cog-wheel
 *     sheet `elementFromPoint` returned `sheet-grab-zone` for 7 sampled rows and
 *     plain `sheet` for 10 more, while the interactive elements it covered
 *     (`sheet-action-info`, `setting-row`, `setting-range`, `tab`,
 *     `search-input`) were reachable only where the overlay had scrolled past.
 *   - `touch-action` is NOT an inherited property: the band computed to `none`
 *     and its `::after` to `auto` — the one layer whose entire job is the drag
 *     was the one layer not covered by the iOS rule that stops the UA claiming a
 *     drag as a scroll. WS53 and WS55 both recorded that binding in JS alone is
 *     not enough on iOS.
 *
 * WHAT REPLACES IT: the band itself, now 44px (was 22px), sticky so it cannot
 * scroll away, and still the only element bound to the drag, with touch-action
 * declared on the element where it applies.
 *
 * THE OWNER'S FIRST INSTRUCTION IS STILL MET — by the band rather than an
 * overlay: the gesture works from a 44px target that stays at the top of the
 * sheet at ANY scroll position, which is strictly more reachable than the 22px
 * strip that used to be the only guaranteed target. A half-screen region that
 * does NOT capture touches is not achievable while the region is an overlay;
 * that trade-off is the owner's to make and is recorded rather than silently
 * resolved.
 */

/* The pill is the only VISIBLE part, drawn at the sheet's top edge where the
   owner's screenshots show it. */
.sheet-grab-zone .sheet-grab {
  position: relative;
  z-index: 1;
}

/* The header must clear the strip, or the title overlaps the pill -- visible in
   the first screenshot of this pass and not caught by any measurement, because
   both elements were "at the top" and neither number was wrong.
 *
 * ITEM 1, FOURTH PASS (correction): the clearance is now the `.sheet` padding
 * above (band-y + 34px), which places the header below the band at EVERY band
 * position. The 26px that used to live here was a guess that only lined up while
   the band sat at y=0; it is now 0 so the two cannot drift apart again. */
.sheet-header {
  padding-top: 0;
}

/* ITEM 1, THIRD PASS. The pill is the one element the owner could SEE the two
 * surfaces disagreed on: the settings sheet showed it with a 14px gap under the
 * title, the Info page with none, so the two bands sat at different heights and
 * neither looked deliberate. The rule below is the single definition and both
 * surfaces use it -- a second pill style is a second thing to keep in step, and
 * this repo has already lost the stylesheet to a stale restore twice.
 *
 * The margin is now 0 and the SPACING is done by padding on each surface's own
 * band, because the two surfaces need different amounts of it: the sheet has a
 * header below the pill, the Info page has one too, but the sheet's band also
 * carries the drag surface the owner wants enlarged. Keeping the gap out of the
 * pill means each surface can be tuned without moving the other.
 */
.sheet-grab {
  width: 40px;
  height: 5px;
  border-radius: 3px;
  background: var(--border);
  margin: 0 auto;
}

.sheet-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 6px;
}

.sheet-title { font-size: 19px; font-weight: 700; }

/* ---- WS77: the sleep-timer sheet's choices ---- */
.sleep-body {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 4px 0 8px;
}
.sleep-option {
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text);
  border-radius: var(--radius-sm);
  padding: 14px 16px;
  font-size: 15px;
  font-weight: 600;
  text-align: left;
  cursor: pointer;
}
.sleep-option:active { background: var(--accent-soft); }
.sleep-cancel { color: var(--danger); border-color: var(--danger); }
.sleep-active {
  font-size: 13.5px;
  color: var(--text-secondary);
  margin: 0 0 4px;
}

.sheet-close {
  border: none;
  background: var(--border);
  color: var(--text);
  width: 32px;
  height: 32px;
  border-radius: 50%;
  font-size: 16px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
}

.sheet-counter {
  font-size: 13.5px;
  color: var(--text-secondary);
  margin-bottom: 14px;
}

.tabs {
  display: flex;
  gap: 8px;
  margin-bottom: 14px;
}

.tab {
  flex: 1;
  padding: 10px 0;
  min-height: 44px;
  border-radius: var(--radius-sm);
  border: none;
  background: var(--surface);
  color: var(--text-secondary);
  font: inherit;
  font-weight: 600;
  font-size: 14px;
  cursor: pointer;
}
.tab[aria-selected="true"] {
  background: var(--accent);
  color: #fff;
}

/* WS67: align the home-screen freshness control with the top-bar cog by sharing
   the same content edge and a 44px target, rather than a pixel offset. */
.podcast-section-heading {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 12px;
}
.podcast-section-heading .section-title { margin-bottom: 0; }

.fresh-btn {
  width: 44px;
  height: 44px;
  flex: none;
  align-self: center;
  border-radius: 50%;
  border: none;
  background: none;
  color: var(--accent);
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
}
.fresh-btn:active { opacity: 0.6; }
.fresh-btn-icon { display: block; width: 22px; height: 22px; }
.fresh-btn-icon svg { display: block; width: 100%; height: 100%; }

.search-input {
  width: 100%;
  padding: 12px 16px;
  min-height: 44px;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text);
  font: inherit;
  font-size: 16px; /* prevents iOS zoom on focus */
  margin-bottom: 12px;
}
.search-input:focus { outline: 2px solid var(--accent); border-color: transparent; }

.pick-list { display: flex; flex-direction: column; gap: 8px; }

.pick-item {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  padding: 10px 12px;
  border-radius: var(--radius-sm);
  border: 1.5px solid var(--border);
  background: var(--surface);
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
  min-height: 56px;
}
.pick-item.selected {
  border-color: var(--accent);
  background: var(--accent-soft);
}
.pick-item:disabled { opacity: 0.45; cursor: default; }

.pick-logo {
  width: 44px;
  height: 44px;
  border-radius: 10px;
  object-fit: cover;
  flex: none;
  background: var(--accent-soft);
}
.pick-placeholder {
  width: 44px;
  height: 44px;
  border-radius: 10px;
  flex: none;
  background: var(--accent-soft);
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--accent);
  font-weight: 700;
  font-size: 18px;
}

.pick-name { font-size: 14.5px; font-weight: 600; line-height: 1.25; }
.pick-sub { font-size: 12px; color: var(--text-secondary); }
/* ---- WS66, CORRECTED: the SOURCE line. ----
 *
 * OWNER, 2026-10-05: "if Teknology represents a real category label we should not
 * remove it, just add a source label."
 *
 * The first version of this change replaced `.pick-sub` with the source and
 * pushed the old text to a `.pick-meta` rule -- which quietly DELETED the
 * description on all 371 Sveriges Radio rows, because their second line WAS
 * their real description ("P4 Jämtland ger dig bevakning där du bor..."), not a
 * category. The rule is deleted rather than left behind: dead CSS is how the
 * two surfaces drifted apart in the first place.
 *
 * So there are now three optional lines and nothing is displaced:
 *   .pick-name  the podcast name
 *   .pick-sub   the provider's OWN text (iTunes: genre; SR: its description)
 *   .pick-src   the source -- feed host + freshness, or "Sveriges Radio"
 *
 * The source is the dimmest because it is the one that is the same on every
 * same-named row except where it matters; the provider's own text is the one a
 * reader actually reads. `overflow-wrap: anywhere` because a feed host is a
 * long unbreakable token and would otherwise widen the row.
 *
 * `line-height: 1.35` on the small lines: three lines inside a 44px-minimum
 * touch target is the case AGENTS.md warns about, and `min-height` is on
 * `.pick-item`, so the row still meets the guidance -- this only stops the
 * lines from crowding each other.
 */
.pick-src {
  font-size: 11.5px;
  color: var(--text-secondary);
  opacity: 0.7;
  line-height: 1.35;
  overflow-wrap: anywhere;
}

/*
 * The newest-episode date, in italic.
 *
 * OWNER, on the shipped WS66 build: "the number of episodes can go and the word
 * senast. put the date in italic". The date is kept deliberately -- it is what
 * separates a live feed from an abandoned one -- and italic is the owner's signal
 * that it is a moment rather than an identity. The host beside it is not
 * italicised, so the two are distinguishable by more than punctuation.
 */
.pick-src-date {
  font-style: italic;
}

/* The separator is a real text node between two elements, so it needs its own
 * spacing. `margin` on the inline elements would also add space at the edges of
 * a line that has only one part, which is why the dot is a styled element. */
.pick-src .pick-src-date::before {
  content: ' · ';
  font-style: normal;
}

.pick-check {
  margin-left: auto;
  width: 24px;
  height: 24px;
  border-radius: 50%;
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--accent);
  color: #fff;
  font-size: 13px;
}
.pick-item .pick-check { visibility: hidden; }
.pick-item.selected .pick-check { visibility: visible; }

/* ---------- toast ---------- */
.toast {
  position: fixed;
  left: 50%;
  bottom: calc(var(--safe-bottom) + 28px);
  transform: translateX(-50%);
  background: var(--text);
  color: var(--bg);
  padding: 12px 20px;
  border-radius: 999px;
  font-size: 14px;
  font-weight: 600;
  z-index: 60;
  box-shadow: var(--shadow);
  max-width: 88vw;
  text-align: center;
}

/* Undo toast (2026-10-02). A removal is destructive and easy to trigger by
   accident, so it always offers a way back. The action is a real <button> so
   it is reachable by keyboard and announced by a screen reader. */
.toast-undo {
  display: flex;
  align-items: center;
  gap: 14px;
  text-align: left;
}
.toast-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.toast-action {
  flex: none;
  border: 1.5px solid currentColor;
  background: transparent;
  color: inherit;
  font: inherit;
  font-weight: 700;
  padding: 5px 12px;
  border-radius: 999px;
  cursor: pointer;
  /* 44px is the accessible minimum target; the visual box is smaller. */
  min-height: 32px;
}
.toast-action:active { background: rgba(255, 255, 255, 0.14); }

/* ---------- narrow viewports (dev panels, small phones) ---------- */
@media (max-width: 340px) {
  .topbar { padding: 10px 12px 6px; }
  .content { padding: 8px 12px 0; }
  .brand { font-size: 18px; }
  .edit-btn { padding: 8px 12px; font-size: 14px; }
  .icon-row { gap: 8px; }
  .stream-icon, .icon-empty { border-radius: 14px; }
  .news-thumb { width: 52px; height: 52px; }
  .reader-body { padding: 14px; }
  /* WS5b: narrow phones get a slightly tighter column rather than a
     percentage text cap, so the channel and programme stay on one line for as
     long as possible and the pills below keep their left edge. */
  .player { --player-gap: 10px; }
}

/* very narrow: icons shrink slightly but stay on one scrollable row */
@media (max-width: 250px) {
  .icon-row, .icon-scroller { gap: 8px; }
  .stream-icon, .icon-empty { border-radius: 14px; }
}

/* ---------- E2: Spotify / YouTube search links in the expanded player ----------
   Icons ONLY, per the brief: no text labels, visually discreet, minimal. The
   glyph is a letter rather than a brand logo -- shipping the real Spotify and
   YouTube marks would be using trademarks the app has no licence for, and a
   plain letter in the brand colour is unmistakable in context.

   The row is a flex sibling inside .expand-row, so it sits beside the song
   text and never pushes the title narrower than it has to. `flex: none` stops
   it being squeezed by a long title. */
/* ---- E2, second pass (2026-10-03): bigger, no circles, right-aligned. ----
 *
 * OWNER, verbatim: "make the icons for spotify and youtube bigger and remove
 * the circles around them instead. also make the distance between the icons
 * slightly bigger to the thumb doesn't risk to click the wrong one. also right
 * side align them on the card. meaning that there is a distance to the song
 * title and that the icons appear for users in the same position regardless of
 * text length to the left on the same row."
 *
 * FOUR changes, three of which are about the THUMB rather than the eye:
 *
 * 1. BIGGER. 14 px -> 20 px. The original brief's "visuellt diskret" is
 *    superseded by an explicit instruction to enlarge, and the stated reason is
 *    reachability.
 * 2. NO CIRCLES. The 26 px round button with a tinted ring and a 10 % tint fill
 *    is GONE -- no border-radius, no border, no background. A ring around a
 *    logo competes with the logo; what is left is the mark itself.
 * 3. MORE SPACE BETWEEN THEM. gap 6 px -> 14 px. With no ring to separate them
 *    the gap IS the separator, and 6 px was close enough that a thumb aiming at
 *    YouTube could land on Spotify.
 * 4. RIGHT-ALIGNED, ALWAYS IN THE SAME PLACE. `margin-left: auto` is the whole
 *    mechanism: it absorbs all the free space, so the pair sits at the right
 *    edge at EVERY title length. Before this, a short title left a wide gap
 *    after the icons and a long one pushed them left, so the icons MOVED
 *    depending on the text to their left -- exactly what the owner asked to
 *    stop. `flex: none` is kept so a long title cannot squeeze them.
 *
 * THE TRADE, stated rather than hidden. The tap target SHRANK from 26x26 to
 * the 20 px glyph plus 7 px padding = 34x34, which is LARGER than the old
 * button. So the reachability goal and the no-circles goal are both met -- but
 * only because the padding is load-bearing. Removing it would silently take the
 * target below the old size, and the owner's stated reason for enlarging is
 * that the thumb should not miss.
 */
.song-links {
  display: flex;
  flex: none;
  align-items: center;
  gap: 4px;
  margin-left: auto;   /* the right-alignment mechanism */
  /* OWNER, 2026-10-07: "align the youtube icon with the cog wheel, flash and
     autoplay icons horisontally. also align the chevron and spotify icons with
     the horizontal position of the information icon." Measured at 390px:
     info cx 302.1, cog/autoplay cx 348.1. With gap 4px + padding-left 11px +
     margin-right 1px the rendered glyph centres land exactly on those columns
     (Spotify 302.1, YouTube 348.1) — driven in the browser, not inferred.
     The 1px margin-right exists because the 42px link box is 2px narrower
     than the 44px topbar buttons; without it both centres sit 1px right. */
  margin-right: 1px;
  padding-left: 11px;  /* a visible floor between a long title and the icons */
  /* WS68: vertical-only. The icons grew to 24px, so the row they sit in needs
   * a floor or the taller glyphs would crowd the title above them. min-height
   * grows DOWNWARD only in a flex column context; here it guarantees the row
   * is tall enough without any horizontal change. */
  min-height: 34px;
}
.song-link {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* WS68: 7px -> 9px. The glyph grew, so the TARGET must grow with it or the
     visible icon would sit inside a smaller tap box than the 34x34 the padding
     comment promises. `padding` shorthand is safe HERE (this rule sets no
     horizontal-alignment role -- `.song-links` uses margin-left:auto, which
     absorbs the free space, so a wider pad does not move the pair). */
  padding: 9px;        /* load-bearing: keeps the target at 38x38 */
  text-decoration: none;
  color: var(--song-tint, var(--text-secondary));
  -webkit-tap-highlight-color: transparent;
}
/* The glyph is now an inline <svg>, not a letter, so it is SIZED rather than
   font-sized. 14 px inside the 26 px button: big enough to read as the logo it
   is, small enough to stay "visuellt diskret" as the E2 brief requires. An
   earlier edit of this rule reported success and did not apply, leaving the
   letter-based sizing in place and the SVG rendering at its default 24 px --
   found by measuring the rendered box in the browser, not by reading the file. */
.song-link-glyph {
  display: block;
  /* WS68: 20px -> 24px. The owner's words: "increase the size of the spotify
     and youtube icon slightly only." 20% is deliberately modest: the request
     was "slightly", and an earlier pass in this file already took these from
     14px to 20px, so there is a real risk of drifting past "slightly" into a
     pair of buttons that competes with the transport. Sized in px, never
     font-size -- an SVG has no font, and the WS53 defect was exactly that. */
  width: 24px;   /* was 20px: "slightly", per the owner 2026-10-05 */
  height: 24px;
}
.song-link:active { opacity: 0.6; }
/* Focus ONLY. A permanent ring would be the circle the owner asked to remove,
   so this appears on keyboard focus and nowhere else. */
.song-link:focus-visible {
  outline: 2px solid var(--song-tint, #888);
  outline-offset: 2px;
  border-radius: 6px;
}

/* ---------- E4: the topbar's right-hand button group ---------- */
.topbar-right {
  display: flex;
  align-items: center;
  /* OWNER, 2026-10-07: "the info icon should move a bit closer to the cog
     wheel". The gap was 4px; 2px pulls the pair together without them
     touching. */
  gap: 2px;
  flex: none;
}
/* ---- E4, second pass (2026-10-03): the Info button LOOKS like the cog. ----
 *
 * OWNER, verbatim: "make the Info button visually to have the same design as
 * the cog wheel icon."
 *
 * MEASURED difference, before this change:
 *
 *   property        .edit-btn (cog)      .info-btn       verdict
 *   --------------  -------------------  --------------  --------------
 *   width/height    44px                 40px            4px smaller
 *   background      var(--accent-soft)   transparent     no tint at all
 *   color           var(--accent)        --text-secondary  muted
 *   border-radius   50%                  50%             same
 *
 * So the shapes already matched and the WEIGHTS did not: the cog is a filled
 * accent disc with a bright glyph, the Info button was a bare outline-less
 * outline. Two buttons of the same shape, sitting side by side, reading as
 * different kinds of control.
 *
 * Every declaration below is copied from .edit-btn rather than invented, so the
 * two cannot drift apart again. `.info-btn svg` is added because the cog sizes
 * its glyph explicitly (`.edit-btn svg { width: 22px }`) while .info-btn never
 * did -- the SVG carried width="22" in the markup and nothing in the CSS, so a
 * stylesheet change to one would have silently missed the other.
 */
.info-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 44px;          /* was 40px -- matches .edit-btn */
  height: 44px;
  padding: 0;
  border: none;
  border-radius: 50%;
  /* OWNER, 2026-10-06: the circle background is removed from both topbar
     buttons. The glyph alone carries the control now. */
  background: none;               /* was var(--accent-soft) */
  color: var(--accent);
  cursor: pointer;
  flex: none;
  -webkit-tap-highlight-color: transparent;
}
.info-btn svg { width: 22px; height: 22px; }
.info-btn:active { opacity: 0.75; }
/* Focus matches the app-wide rule (`.edit-btn:focus-visible, button:focus-
   visible`), so it is accent-coloured like every other control rather than the
   old bespoke --text ring. */
.info-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
