/* Schibsted Grotesk for prose, IBM Plex Mono for machine data. Both OFL-1.1
   and self-hosted (see fonts/LICENSE-*.txt), so the UI makes no third-party
   request and works on a box with no outbound network.

   Schibsted Grotesk is an editorial grotesque: it holds a 13px meta row and
   still has a voice at 38px, which is the whole range this interface uses.
   Plex Mono is the machine half of the pair — flat terminals and unambiguous
   figures at 12px, where the dates and durations live. */
@font-face {
  font-family: 'Schibsted Grotesk';
  src: url('/static/fonts/schibsted-grotesk-var.woff2') format('woff2');
  font-weight: 400 900;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'IBM Plex Mono';
  src: url('/static/fonts/ibm-plex-mono-400.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'IBM Plex Mono';
  src: url('/static/fonts/ibm-plex-mono-500.woff2') format('woff2');
  font-weight: 500;
  font-style: normal;
  font-display: swap;
}

:root {
  --bg: #fbfaf8;
  /* Panels that lift off the page. On warm paper a true white reads as a
     sheet laid on top, which is the only elevation this UI needs. */
  --surface: #ffffff;
  --text: #141419;
  --muted: #6e6d67;
  --line: #e5e3dd;
  /* Form-control boundaries need 3:1 against --bg (WCAG 1.4.11); --line is a
     decorative hairline at 1.2:1 and cannot bound a field on its own. */
  --line-strong: #8a8882;
  --hover: #f1efeb;
  --err: #a63a28;
  --err-soft: #f9e3e1;
  --pending: #3b6ea5;

  /* Artwork is the content of this medium, so it is the one thing treated as a
     physical object: a hairline edge so the crop stays crisp against the page,
     and a shadow with real offset and blur so it sits on the paper rather than
     being pasted into it. */
  --ring: rgba(20, 20, 25, 0.13);
  --shadow-art: 0 1px 2px rgba(20, 20, 25, 0.06), 0 10px 22px -14px rgba(20, 20, 25, 0.35);
  --shadow-art-lg: 0 2px 4px rgba(20, 20, 25, 0.06), 0 30px 60px -30px rgba(20, 20, 25, 0.45);
  /* The far end of an empty frame's breath. One step off --surface: enough to
     read as waiting, not so much that a list of them strobes. */
  --art-wait: #f1efeb;

  /* --accent is set per feed from its own cover art (see color.ts) and is
     absent everywhere else, so every use has to name its neutral fallback.
     accentPair guarantees 4.5:1 against the theme background, which clears
     both text and the 3:1 a focus ring needs. */

  --font-ui: 'Schibsted Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif;
  --font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;

  /* Type scale. Integers only: fractional sizes render inconsistently across
     platforms and read as drift rather than hierarchy. */
  --fs-micro: 11px;
  --fs-mono: 12px;
  --fs-sm: 13px;
  --fs-base: 15px;
  --fs-brand: 16px;
  --fs-title: 26px;
  --fs-display: 30px;
  --fs-hero: 38px;

  /* Weights: 400/500/600. Real instances of the variable sans, and the two
     cuts Plex Mono ships, so nothing is synthesised. */
  --w-med: 500;
  --w-bold: 600;

  /* Radius by element size, not element type: controls, rows and small
     artwork share one value; only artwork at 96px and up steps up. */
  --r: 6px;
  --r-lg: 10px;

  /* One measure. The header rule, the row hairlines and the panel edges all
     resolve to the same content width, so nothing on the page bleeds past
     anything else. --measure is the outer box; the content inside it is
     --measure less both gutters, which is what those edges have to match. */
  --measure: 720px;
  --gutter: 20px;
  --content: calc(var(--measure) - var(--gutter) * 2);
}
@media (prefers-color-scheme: dark) {
  :root {
    --bg: #141417;
    --surface: #1a1a1e;
    --text: #e9e9e6;
    --muted: #8f8f96;
    --line: #2b2b30;
    --line-strong: #636368;
    --hover: #1d1d21;
    --err: #dd8a72;
    --err-soft: #3a2523;
    --pending: #7da7d4;

    /* A shadow cast onto near-black does almost nothing, so the edge carries
       the work here and the shadow only anchors the object. */
    --ring: rgba(255, 255, 255, 0.11);
    --shadow-art: 0 1px 2px rgba(0, 0, 0, 0.5), 0 12px 26px -16px rgba(0, 0, 0, 0.8);
    --shadow-art-lg: 0 2px 6px rgba(0, 0, 0, 0.55), 0 34px 64px -34px rgba(0, 0, 0, 0.9);
    /* Lighter here rather than darker: on near-black a frame going darker
       reads as a hole in the page instead of a surface waiting to be filled. */
    --art-wait: #24242a;
  }
}
/* Lets a length transition resolve against `auto`, which is what the add
   panel's height animation needs; nothing else here animates to a keyword. */
:root { interpolate-size: allow-keywords; }
* { box-sizing: border-box; }
html {
  /* The scrollbar is part of the page even though we did not draw it. */
  scrollbar-color: var(--line-strong) transparent;
  scrollbar-width: thin;
}
body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font: var(--fs-base)/1.55 var(--font-ui);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
}
/* On a feed page the selection carries that show's colour; everywhere else it
   falls back to ink. Solid declaration first for engines without color-mix. */
::selection { background: var(--hover); color: var(--text); }
::selection {
  background: color-mix(in srgb, var(--accent, var(--text)) 20%, transparent);
  color: var(--text);
}
:focus-visible { outline: 2px solid var(--accent, var(--text)); outline-offset: 3px; }
a { text-underline-offset: 3px; text-decoration-thickness: 1px; }

/* Machine-set values: dates, durations, tallies. crosscast manufactures a
   machine format, so its data is set in the machine face and its prose is
   not. Tabular figures keep columns of numbers aligned down a list.

   No size compensation. The usual reason to set a mono smaller than the prose
   around it is that mono faces tend to run large on the same em — true of the
   system mono this used to be set in. It is not true of this pair: measured at
   13px, Plex Mono's x-height is 6.71 against Schibsted's 6.86 and its digits
   are 7.80 wide against 7.95. It is already the marginally smaller face, so
   shrinking it to 0.92em only made the durations look undersized next to their
   dates. Matched ems, matched sizes. */
.num {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-weight: 400;
}

/* The header resolves to the same measure as the content and its rule stops
   where the row hairlines stop, so the chrome belongs to the page instead of
   spanning a window the content never uses. The brand is inset to the 10px
   the rows inset their artwork, putting the wordmark on the artwork edge. */
header.site { padding: 0 var(--gutter); }
.site-inner {
  max-width: var(--content);
  margin: 0 auto;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 22px 10px 20px;
  border-bottom: 1px solid var(--line);
}
.brand {
  font-weight: var(--w-bold);
  font-size: var(--fs-brand);
  color: var(--text);
  text-decoration: none;
  letter-spacing: -0.02em;
}
/* No flex gap here: the buttons already carry 10px of their own padding, and
   adding a gap on top of it spaces the two controls further apart than the
   name sits from the first one. The name pays the same 10px instead, so the
   three labels read as one evenly set row. */
.user-chip {
  margin-left: auto;
  display: flex;
  align-items: center;
  font-size: var(--fs-sm);
  color: var(--muted);
}
.user-chip .avatar {
  width: 24px;
  height: 24px;
  border-radius: 50%;
  align-self: center;
  margin-right: 8px;
  box-shadow: 0 0 0 1px var(--ring);
}
.user-chip form { display: inline; }
.user-name { white-space: nowrap; padding-right: 10px; }

/* Narrow screens: drop the name, the avatar stands in for it, so the header
   stays on one aligned row. */
@media (max-width: 480px) {
  /* Narrowing the gutter here rather than the header's own padding keeps the
     header rule on the same edges as the row hairlines below it. */
  :root { --gutter: 16px; }
  .site-inner { padding: 16px 10px; }
  .user-chip:has(.avatar) .user-name { display: none; }
}

main { max-width: var(--measure); margin: 0 auto; padding: 36px var(--gutter) 96px; }

/* Flashes: quiet single lines. Only the in-progress flash carries a dot, and
   it earns it by standing for live state; errors are marked by the tint. */
.flash { font-size: var(--fs-sm); margin: 0 0 20px; color: var(--muted); }
.flash-info::before {
  content: '';
  display: inline-block;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--pending);
  margin-right: 8px;
  vertical-align: 1px;
}
.flash-err {
  color: var(--err);
  background: var(--err-soft);
  padding: 10px 14px;
  border-radius: var(--r);
}
.flash-err a { color: inherit; }

/* Labels sit above their control; a placeholder never stands in for one. */
.field-label {
  display: block;
  font-size: var(--fs-sm);
  color: var(--muted);
  margin-bottom: 8px;
}

/* The page has exactly one list and needs a heading for it. On screen the list
   is self-evident and a visible title would only repeat the tab. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* Adding a feed is the page's occasional action, not its subject, so it wears
   an empty slot at the foot of the shelf rather than a filled button above it:
   same row height, same 48px art square, same 14px gutter as the feeds it
   makes. Quieter than a real row — muted and medium against text and bold —
   because a show you already have outranks the one you might add. It takes the
   hairline on its top edge, continuing the run of rows it follows. */
.add { border-top: 1px solid var(--line); }
/* First run: the panel follows the empty-state copy, not a run of rows, so the
   hairline has nothing to continue and would draw a line under nothing. The
   copy's own 72px keeps the two apart — the same gap it held when the form sat
   above it. */
.feed-list:has(.empty) + .add { border-top: 0; }
.add-open {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 14px 10px;
  border-radius: var(--r);
  color: var(--muted);
  cursor: pointer;
  list-style: none;
  transition: color 140ms ease, background-color 140ms ease;
}
.add-open::-webkit-details-marker { display: none; }
.add-open:hover { background: var(--hover); color: var(--text); }
/* Filled rather than framed: an empty outline in the artwork column reads as
   a picture that failed to load, where a surface reads as a slot. */
.add-mark {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 48px;
  height: 48px;
  background: var(--surface);
  border: 1px solid var(--line-strong);
  border-radius: var(--r);
  flex-shrink: 0;
  transition: border-color 140ms ease, color 140ms ease;
}
/* The whole affordance resolves to full strength on hover, mark included: the
   hover fill is lighter than --bg in the dark theme, where --line-strong
   against it falls to 2.8:1 and stops clearing WCAG 1.4.11 as a boundary. */
.add-open:hover .add-mark { border-color: var(--text); color: var(--text); }
/* The mark turns into a close affordance rather than swapping icon. */
.add-mark svg {
  width: 18px;
  height: 18px;
  transition: transform 260ms cubic-bezier(0.16, 1, 0.3, 1);
}
.add[open] .add-mark svg { transform: rotate(45deg); }
.add-label { font-size: var(--fs-base); font-weight: var(--w-med); letter-spacing: -0.01em; }
/* The panel opens on the same 260ms curve the mark rotates on, so the plus
   turning into a close and the form arriving read as one movement rather than
   two. ::details-content is the only handle on the part <details> reveals;
   browsers without it open the panel exactly as they did before. */
@media (prefers-reduced-motion: no-preference) {
  .add::details-content {
    block-size: 0;
    overflow: hidden;
    transition:
      block-size 260ms cubic-bezier(0.16, 1, 0.3, 1),
      content-visibility 260ms allow-discrete;
  }
  .add[open]::details-content { block-size: auto; }
}

.add-form { padding: 6px 10px 22px; }
/* Wraps rather than compressing: below ~360px the field and its button cannot
   share a line without the field shrinking past a readable URL. */
.add-row { display: flex; flex-wrap: wrap; gap: 10px; }
.add-form input {
  flex: 1;
  min-width: 12rem;
  padding: 11px 13px;
  border: 1px solid var(--line-strong);
  border-radius: var(--r);
  background: var(--surface);
  color: var(--text);
  font-size: var(--fs-base);
  font-family: inherit;
  caret-color: var(--accent, var(--text));
  transition: border-color 140ms ease, box-shadow 140ms ease;
}
/* Full opacity: dimming muted any further drops the placeholder below 4.5:1. */
.add-form input::placeholder { color: var(--muted); }
input:focus-visible, textarea:focus-visible {
  border-color: var(--accent, var(--text));
  outline-offset: 1px;
}

/* Buttons are text by default; the add-form submit is the one filled control. */
button, .btn {
  border: 0;
  background: none;
  padding: 8px 10px;
  border-radius: var(--r);
  font-size: var(--fs-sm);
  font-family: inherit;
  font-weight: var(--w-med);
  line-height: 1.4;
  color: var(--muted);
  cursor: pointer;
  text-decoration: none;
  display: inline-block;
  white-space: nowrap;
  transition: color 140ms ease, background-color 140ms ease, opacity 140ms ease;
}
button:hover, .btn:hover {
  color: var(--text);
  background: var(--hover);
}
button:active, .btn:active { transform: translateY(1px); }
.add-form button {
  background: var(--text);
  color: var(--bg);
  font-weight: var(--w-bold);
  font-size: var(--fs-base);
  padding: 11px 20px;
  border-radius: var(--r);
  letter-spacing: -0.01em;
}
.add-form button:hover { background: var(--text); color: var(--bg); opacity: 0.86; }
.btn-danger { color: var(--err); }
.btn-danger:hover { color: var(--err); background: var(--err-soft); }

.empty {
  color: var(--muted);
  text-align: center;
  margin: 72px auto;
  max-width: 42ch;
  font-size: var(--fs-base);
  text-wrap: balance;
}

.login-box {
  max-width: 420px;
  margin: 14vh auto 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 20px;
  text-align: center;
}
/* The value proposition is the loudest thing on the page, not the quietest. */
.login-title {
  margin: 0;
  font-size: var(--fs-hero);
  font-weight: var(--w-bold);
  letter-spacing: -0.035em;
  line-height: 1.08;
  text-wrap: balance;
}
.login-lede { color: var(--muted); font-size: var(--fs-base); margin: 0; max-width: 38ch; }

.settings-title {
  margin: 4px 0 24px;
  font-size: var(--fs-title);
  font-weight: var(--w-bold);
  letter-spacing: -0.03em;
}
.settings-section { border-top: 1px solid var(--line); padding-top: 20px; max-width: 640px; }
/* Small labels are machine-set: they name a field rather than speak to you. */
.section-label {
  display: block;
  font-family: var(--font-mono);
  font-size: var(--fs-micro);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.11em;
  color: var(--muted);
}
.settings-status { font-size: var(--fs-sm); margin: 14px 0 10px; }
.cookie-input {
  width: 100%;
  min-height: 150px;
  padding: 12px 14px;
  border: 1px solid var(--line-strong);
  border-radius: var(--r);
  background: var(--surface);
  color: var(--text);
  font: var(--fs-mono)/1.6 var(--font-mono);
  caret-color: var(--accent, var(--text));
  resize: vertical;
  transition: border-color 140ms ease;
}
.cookie-input::placeholder { color: var(--muted); }
.btn-primary {
  background: var(--text);
  color: var(--bg);
  font-weight: var(--w-bold);
  padding: 10px 18px;
  border-radius: var(--r);
}
.btn-primary:hover { background: var(--text); color: var(--bg); opacity: 0.86; }
.btn-google {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  background: var(--text);
  color: var(--bg);
  font-weight: var(--w-bold);
  font-size: var(--fs-base);
  padding: 13px 24px;
  border-radius: var(--r);
  letter-spacing: -0.01em;
}
.btn-google:hover { background: var(--text); color: var(--bg); opacity: 0.86; }
.btn-google svg { width: 18px; height: 18px; flex-shrink: 0; }

/* Status words inside meta rows. The word carries the meaning; colour only
   reinforces it, so the state survives without colour perception. */
.status { font-weight: var(--w-med); }
.status-err { color: var(--err); }
.status-pending { color: var(--pending); }

/* Metadata separates items with gap, not middle dots: a dot-chained line of
   five facts scans as one run-on string. */
.meta-row {
  display: flex;
  flex-wrap: wrap;
  /* Baseline, not the default stretch. These items mix faces and sizes — a
     13px date beside a 12px duration — and stretch gives each its own equal
     box, then lays the text out from the top of it. The two line boxes differ
     by 1.6px, so the runs sit on visibly different baselines. */
  align-items: baseline;
  gap: 1px 14px;
  color: var(--muted);
  font-size: var(--fs-sm);
  margin-top: 3px;
}

/* An icon-led figure: glyph, then the number it labels. The svg is inline
   rather than a flex child on purpose — an inline-flex .stat would hand the
   meta row the icon's bottom edge as its baseline and float the whole figure
   above the author name beside it. As an inline box the baseline stays the
   text's, and vertical-align seats the glyph against it. */
.stat { white-space: nowrap; }
.stat svg {
  width: 14px;
  height: 14px;
  vertical-align: -0.18em;
  margin-right: 5px;
  /* The glyph is a label, not a reading — it should never out-shout its
     number, which is the part anyone scanning the shelf is actually after. */
  opacity: 0.72;
}

/* Feed list: hairline-divided rows. */
.feed-list { display: flex; flex-direction: column; }
.feed-card {
  display: flex;
  gap: 14px;
  padding: 14px 10px;
  align-items: center;
  text-decoration: none;
  color: inherit;
  border-top: 1px solid var(--line);
  border-radius: var(--r);
  transition: background-color 140ms ease;
}
.feed-card:first-child { border-top: 0; }
/* A row is the one control on this page a phone can press, and until the next
   page paints the press was the only thing that told you it landed. Hover is
   gated with it: on a touch screen there is no pointer to leave, so an ungated
   hover stays lit on the last row tapped. */
.feed-card:active { background: var(--hover); }
@media (hover: hover) and (pointer: fine) {
  .feed-card:hover { background: var(--hover); }
}
/* Without min-width:0 a long title cannot shrink below its content width and
   pushes the artwork and row padding out of the container. */
.feed-card-body, .feed-head-body { flex: 1; min-width: 0; }
.feed-art, .ep-art {
  width: 48px;
  height: 48px;
  border-radius: var(--r);
  /* Clips the picture to the frame's corners. An element's own box-shadow is
     not clipped by its own overflow, so the artwork still casts. */
  overflow: hidden;
  flex-shrink: 0;
  background: var(--surface);
  box-shadow: 0 0 0 1px var(--ring), var(--shadow-art);
}
/* Cover art is the visual language of this medium; on a feed's own page it
   gets room to act like one rather than sitting in as a row bullet. */
.feed-art-lg {
  width: 188px;
  height: 188px;
  border-radius: var(--r-lg);
  overflow: hidden;
  flex-shrink: 0;
  background: var(--surface);
  box-shadow: 0 0 0 1px var(--ring), var(--shadow-art-lg);
}
/* Nothing to cast a shadow from: an absent cover is a gap in the shelf, not an
   object sitting on it. A cover whose source has gone is the same gap — it just
   took a round trip to find out. */
.art-placeholder,
.art-frame:has(.art-img.is-missing) { background: var(--surface); box-shadow: 0 0 0 1px var(--ring); }
.art-img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: opacity 200ms ease;
}
/* Scoped to .js, because the load handler that reveals the picture is script:
   where it cannot run, artwork must not be hidden in the first place. */
.js .art-img:not(.is-ready) { opacity: 0; }
/* Neither arrived nor given up — still on the wire. The empty frame breathes so
   a slow upstream thumbnail reads as coming rather than as never having been. */
.js .art-frame:has(.art-img:not(.is-ready):not(.is-missing)) {
  animation: art-wait 1.6s ease-in-out infinite;
}
@keyframes art-wait {
  0%, 100% { background-color: var(--surface); }
  50% { background-color: var(--art-wait); }
}
/* Source titles can run long; unclamped they make every row a different
   height and break the list's vertical rhythm. */
.feed-card-title, .ep-title {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
  /* Tighter than the body's 1.55: nearly every title wraps to two lines on a
     phone, and prose leading on a two-line title reads as two separate rows
     rather than one name. */
  line-height: 1.3;
}
.feed-card-title {
  font-weight: var(--w-bold);
  font-size: var(--fs-base);
  letter-spacing: -0.011em;
}

/* Inset by the same 10px the rows inset their artwork, so the cover column
   runs unbroken from the hero down through every episode below it. */
.feed-head { display: flex; gap: 26px; margin: 4px 0 10px; padding: 0 10px; }
.feed-head h1 {
  margin: 0 0 8px;
  font-size: var(--fs-display);
  font-weight: var(--w-bold);
  letter-spacing: -0.032em;
  line-height: 1.12;
  overflow-wrap: anywhere;
  text-wrap: balance;
}
.feed-desc {
  color: var(--muted);
  font-size: var(--fs-sm);
  margin: 12px 0 0;
  white-space: pre-line;
  max-width: 56ch;
}
.actions { display: flex; align-items: center; gap: 4px; margin: 16px 0 0 -10px; flex-wrap: wrap; }
.actions form { display: inline; }
/* Text buttons hang left so their label lines up with the content above; a
   filled button shows its box edge, so that row sits flush instead. */
.actions:has(.btn-primary) { margin-left: 0; }

/* The feed URL is something you hand to an app, not something you read, so the
   block is the two ways to hand it over and nothing else — no panel, no
   readonly field, no disclosure spelling the URL out. Copying it is what the
   page is for; reading the characters never was. */
.handoff {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 4px;
  /* Flush left, not hung like .actions: a filled button shows its box edge, so
     the edge is what has to meet the content edge the rows and header align
     to. Hanging it 10px would put the one strongest control on the page
     outside every other left edge. */
  margin: 22px 0 14px;
}
/* Wide enough for "Copied", so confirming the copy does not resize the button
   the pointer is still sitting on. */
.btn-copy { min-width: 7.5rem; text-align: center; position: relative; }
/* The handoff this page exists for deserves a confirmation you can watch
   happen. The labels cross-fade and the new one rises the last few pixels into
   place, so the motion points at the outcome rather than swapping a word out
   from under the cursor. */
.copy-label, .copy-done { transition: opacity 140ms ease, transform 140ms ease; }
.copy-done {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  opacity: 0;
  transform: translateY(4px);
}
.btn-copy.is-copied .copy-label { opacity: 0; transform: translateY(-4px); }
.btn-copy.is-copied .copy-done { opacity: 1; transform: none; }
.hint { color: var(--muted); font-size: var(--fs-sm); margin: 12px 0 0; max-width: 68ch; }

/* Episode list: hairline-divided rows. */
.ep-list { list-style: none; margin: 0; padding: 0; }
.ep {
  display: flex;
  gap: 14px;
  align-items: center;
  padding: 12px 10px;
  border-top: 1px solid var(--line);
  border-radius: var(--r);
  transition: background-color 140ms ease;
}
.ep:first-child { border-top: 0; }
.ep:hover { background: var(--hover); }
/* Dim the artwork, not the text: a blanket opacity compounds with the already
   muted meta row and drops it to about 2.2:1. */
.ep-unavailable .ep-art { opacity: 0.5; }
.ep-unavailable .ep-title { color: var(--muted); }
.ep-body { flex: 1; min-width: 0; }
/* Prose is not fixed-width — month names vary as much as the digits do — so
   without a track the durations beside it land at a different x on every row.
   Each date form gets a track sized to itself: 3.4em clears "Sep 30" (42.3px),
   6.4em clears "Sep 30, 2025" (79.2px). Sizing both to the longer one would
   align every row and then strand "Jul 14" 60px from its duration. See
   dateCell() in views.ts for why the step between the two never shows. */
.ep-date { min-width: 3.4em; }
.ep-date-long { min-width: 6.4em; }
.ep-title { font-weight: var(--w-med); font-size: var(--fs-base); letter-spacing: -0.008em; }
.ep-title a { color: inherit; text-decoration: none; }
.ep-title a:hover { text-decoration: underline; }
/* The mark and the target are different sizes on purpose. Drawing a 44px ring
   on every row turns a list of episodes into a column of buttons — worst on
   touch, which has no hover to hide it behind. So the control is a bare glyph
   the width of the mark, and the fingertip target is an invisible pad around
   it: the full 44px to press, none of it claimed from the row. */
.ep-audio {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  color: var(--muted);
  text-decoration: none;
  border-radius: 50%;
  flex-shrink: 0;
  transition: color 140ms ease, background-color 140ms ease, transform 120ms ease-out;
}
.ep-audio::after {
  content: '';
  position: absolute;
  /* 28 + 8 either side = 44. Stays inside the row's 14px gutter, so it never
     steals a press from the title beside it. */
  inset: -8px;
}
/* Scale rather than the buttons' 1px nudge: this target is a 28px circle, and
   a nudge that reads on a wide button is invisible on one. Pressed state only,
   so the feedback is on pointer-down where Apple puts it, not on release. */
.ep-audio:active { transform: scale(0.88); }
@media (hover: hover) and (pointer: fine) {
  .ep:hover .ep-audio { color: var(--text); }
  .ep-audio:hover { color: var(--accent, var(--text)); background: var(--hover); }
}
.ep-audio svg { width: 16px; height: 16px; display: block; }

/* An import is the one moment this interface is genuinely live. Rows that
   arrive mid-poll announce themselves; nothing else on the page moves. */
@keyframes row-in {
  from { opacity: 0; transform: translateY(-8px); filter: blur(3px); }
  to { opacity: 1; transform: none; filter: blur(0); }
}
.is-new { animation: row-in 520ms cubic-bezier(0.16, 1, 0.3, 1); }

@media (max-width: 560px) {
  .feed-head { flex-direction: column; gap: 18px; }
  .feed-art-lg { width: 148px; height: 148px; }
  .feed-head h1 { font-size: var(--fs-title); }
  .login-title { font-size: var(--fs-display); }
  main { padding: 28px var(--gutter) 80px; }
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
  /* Reduced motion asks for less travel, not less legibility. These two are
     opacity alone once their offsets are dropped — nothing crosses the screen,
     and without them a cover would snap in and the copy confirmation would go
     back to reading as a glitch. */
  .art-img, .copy-label, .copy-done { transition-duration: 200ms !important; }
  .copy-done, .btn-copy.is-copied .copy-label { transform: none; }
}
