/* =========================================================
   MYTV TRACKER — "VELVET" DESIGN SYSTEM
   Load this file AFTER style.css.

   Direction: a warm, theatre-dark media tracker. The existing
   oxblood rose is kept as the brand and sharpened, rather than
   swapped for the usual neutral-black media-app look. Poster art
   supplies the colour; the chrome stays quiet around it.

   Both themes are first class. The palette follows the OS by
   default and can be overridden from Settings, which writes
   data-theme onto <html>.
   ========================================================= */

/* ---------------------------------------------------------
   1. TOKENS
   The legacy names (--background, --surface, --accent …) are
   redefined rather than replaced, so the 338 existing var()
   references in style.css inherit the new system for free.
   --------------------------------------------------------- */

:root {
  color-scheme: light;

  /* Core surfaces */
  --background: #fbf5f4;
  --surface: #ffffff;
  --surface-light: #f6edec;
  --surface-raised: #ffffff;

  /* Ink */
  --text: #1c1416;          /* 16.77:1 on --background  AAA */
  --text-strong: #120c0e;
  --muted: #7a686b;         /*  4.84:1 on --background  AA  */
  --faint: #806d6b;         /*  4.51:1 on --background  AA  — was #a2918f at 2.79:1, which
                                 failed AA on every eyebrow, stat label and inactive nav item */

  /* Brand */
  --accent: #9b2242;
  --accent-dark: #75182f;
  --accent-light: #f7e2e6;
  --accent-contrast: #ffffff;
  --accent-ring: rgba(155, 34, 66, 0.28);

  /* Lines and depth. The first pass sat at 1.23:1 against the page,
     which is invisible — every card was leaning entirely on its shadow,
     which is why light mode read as flat. */
  --border: #d5b2ac;        /* 1.80:1 on --background — decorative separators */
  --border-strong: #b98981; /* 3.02:1 on --surface — control boundaries, WCAG 1.4.11 */

  /* Season grid */
  --strip-empty: #f6edec;
  --strip-empty-border: #cfa9a3;   /* 2.16:1 on --background — the unwatched cell has to
                                      be legible; at the old 1.07:1 it was invisible */
  --shadow: rgba(70, 30, 36, 0.1);
  --shadow-card: 0 1px 2px rgba(70, 30, 36, 0.05),
                 0 8px 24px -12px rgba(70, 30, 36, 0.18);
  --shadow-raised: 0 2px 4px rgba(70, 30, 36, 0.06),
                   0 18px 40px -18px rgba(70, 30, 36, 0.28);

  /* Status */
  --positive: #2f7d5d;
  --warning: #9a6212;
  --danger: #b3261e;

  /* Poster placeholder gradient */
  --poster-a: #f2dcdf;
  --poster-b: #e6c6cc;

  /* Type */
  --font-display: "Bricolage Grotesque", "Helvetica Neue", Helvetica, sans-serif;
  --font-body: "Instrument Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  --font-mono: "DM Mono", ui-monospace, SFMono-Regular, Menlo, monospace;

  --step--2: 0.6875rem;
  --step--1: 0.8125rem;
  --step-0: 0.9375rem;
  --step-1: 1.0625rem;
  --step-2: 1.375rem;
  --step-3: 1.75rem;
  --step-4: 2.25rem;

  /* Space */
  --s-1: 4px;
  --s-2: 8px;
  --s-3: 12px;
  --s-4: 16px;
  --s-5: 20px;
  --s-6: 24px;
  --s-7: 32px;
  --s-8: 40px;
  --s-9: 56px;

  /* Radius */
  --r-sm: 8px;
  --r-md: 12px;
  --r-lg: 18px;
  --r-xl: 26px;
  --r-full: 999px;

  /* Motion */
  --ease: cubic-bezier(0.22, 0.61, 0.36, 1);
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
  --dur-1: 120ms;
  --dur-2: 200ms;
  --dur-3: 320ms;
}

/* Dark, following the OS unless Settings has pinned light. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    color-scheme: dark;

    --background: #121014;
    --surface: #1a171c;
    --surface-light: #241f27;
    --surface-raised: #221d24;

    --text: #f3edee;          /* 16.36:1 on --background  AAA */
    --text-strong: #ffffff;
    --muted: #a0919a;         /*  6.32:1 on --background  AA  */
    --faint: #877881;         /*  4.54:1 on --background  AA  — was #7d6f78 at 3.98:1 */

    --accent: #e5566f;
    --accent-dark: #c8455c;
    --accent-light: #33212a;
    --accent-contrast: #1a0d12;
    --accent-ring: rgba(229, 86, 111, 0.36);

    --border: #463d49;        /* 1.82:1 on --background — was #2e2830 at 1.32:1 */
    --border-strong: #6e6072; /* 3.02:1 on --surface */
    --strip-empty: #241f27;
    --strip-empty-border: #53485a;  /* 2.20:1 on --background */
    --shadow: rgba(0, 0, 0, 0.5);
    --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.4),
                   0 8px 24px -12px rgba(0, 0, 0, 0.7);
    --shadow-raised: 0 2px 4px rgba(0, 0, 0, 0.45),
                     0 18px 40px -18px rgba(0, 0, 0, 0.8);

    --positive: #5fc79a;
    --warning: #e0a94a;
    --danger: #f2685f;

    --poster-a: #2b232c;
    --poster-b: #3a2c34;
  }
}

/* Settings override wins over the OS in both directions. */
:root[data-theme="dark"] {
  color-scheme: dark;

  --background: #121014;
  --surface: #1a171c;
  --surface-light: #241f27;
  --surface-raised: #221d24;

  --text: #f3edee;      /* 16.36:1 on --background  AAA */
  --text-strong: #ffffff;
  --muted: #a0919a;     /*  6.32:1 on --background  AA  */
  --faint: #877881;     /*  4.54:1 on --background  AA  — was #7d6f78 at 3.98:1 */

  --accent: #e5566f;
  --accent-dark: #c8455c;
  --accent-light: #33212a;
  --accent-contrast: #1a0d12;
  --accent-ring: rgba(229, 86, 111, 0.36);

  --border: #463d49;    /* 1.82:1 on --background — was #2e2830 at 1.32:1 */
  --border-strong: #6e6072; /* 3.02:1 on --surface */
  --strip-empty: #241f27;
  --strip-empty-border: #53485a;  /* 2.20:1 on --background */
  --shadow: rgba(0, 0, 0, 0.5);
  --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.4),
                 0 8px 24px -12px rgba(0, 0, 0, 0.7);
  --shadow-raised: 0 2px 4px rgba(0, 0, 0, 0.45),
                   0 18px 40px -18px rgba(0, 0, 0, 0.8);

  --positive: #5fc79a;
  --warning: #e0a94a;
  --danger: #f2685f;

  --poster-a: #2b232c;
  --poster-b: #3a2c34;
}

/* ---------------------------------------------------------
   2. FOUNDATION
   --------------------------------------------------------- */

body {
  background: var(--background);
  color: var(--text);
  font-family: var(--font-body);
  font-size: var(--step-0);
  line-height: 1.5;
  letter-spacing: -0.006em;
  -webkit-font-smoothing: antialiased;
  transition: background-color var(--dur-theme) var(--ease-theme),
              color var(--dur-theme) var(--ease-theme);
}

h1, h2, h3, h4 {
  font-family: var(--font-display);
  color: var(--text-strong);
  letter-spacing: -0.022em;
  line-height: 1.12;
  font-weight: 700;
}

/* Episode codes, counts and stat numerals are data, so they are
   set in the mono face. This is the one place the interface
   deliberately looks like a listings grid. */
.stat-number,
.episode-code,
.season-strip-caption,
.home-episode-code,
.details-progress-text {
  font-family: var(--font-mono);
  font-feature-settings: "tnum" 1;
  letter-spacing: -0.02em;
}

.eyebrow {
  font-family: var(--font-mono);
  font-size: var(--step--2);
  font-weight: 500;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--faint);
}

::selection {
  background: var(--accent);
  color: var(--accent-contrast);
}

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  border-radius: var(--r-sm);
}

/* ---------------------------------------------------------
   3. SHELL — header, nav, page rhythm
   The old header cost ~200px on every screen and repeated a
   heading the page already had. It is now a 52px identity bar,
   so the page's own title is the only heading on screen.
   --------------------------------------------------------- */

.app-container {
  background: var(--background);
}

.top-header {
  position: sticky;
  top: 0;
  z-index: 40;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-3);
  min-height: 52px;
  padding: var(--s-3) var(--s-5);
  background: color-mix(in srgb, var(--background) 82%, transparent);
  backdrop-filter: saturate(1.4) blur(14px);
  -webkit-backdrop-filter: saturate(1.4) blur(14px);
  border-bottom: 1px solid transparent;
  transition: border-color var(--dur-2) var(--ease);
}

.top-header.is-stuck {
  border-bottom-color: var(--border);
}

.top-header .welcome-text {
  display: none;
}

.top-header h1 {
  margin: 0;
  font-size: var(--step-1);
  font-weight: 800;
  letter-spacing: -0.03em;
}

.tv-sync-status {
  margin: 2px 0 0;
  font-family: var(--font-mono);
  font-size: var(--step--2);
  letter-spacing: 0.02em;
  color: var(--faint);
}

.tv-sync-status[data-state="error"] {
  color: var(--danger);
}

.profile-circle {
  width: 44px;
  height: 44px;
  flex: 0 0 44px;
  display: grid;
  place-items: center;
  border: 1px solid var(--border);
  border-radius: var(--r-full);
  background: var(--surface);
  color: var(--accent);
  font-family: var(--font-display);
  font-weight: 700;
  font-size: var(--step-0);
  box-shadow: none;
  transition: transform var(--dur-1) var(--ease),
              border-color var(--dur-2) var(--ease);
}

.profile-circle:hover {
  border-color: var(--accent);
  transform: translateY(-1px);
}

.profile-circle:active {
  transform: scale(0.94);
}

/* The bottom nav is fixed, so the last row of any list was rendering
   underneath it. Reserve its height plus the home-indicator inset. */
main {
  padding-bottom: calc(96px + env(safe-area-inset-bottom));
}

/* Page headings */
.page-title {
  padding: var(--s-6) 0 var(--s-5);
}

/* A section heading and the card's own heading were both flat h3s, so
   Settings read as one two-line title instead of section → card. */
.settings-card > h3:first-child,
.settings-card > .settings-card-heading {
  margin: 0 0 var(--s-4);
  font-size: var(--step-2);
}

.settings-card .import-heading h3,
.settings-card .import-heading + * {
  font-size: var(--step-1);
}

.settings-card .import-heading h3 {
  font-size: var(--step-0);
  font-family: var(--font-mono);
  font-weight: 500;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--faint);
}

.page-title h2 {
  margin: var(--s-1) 0 0;
  font-size: var(--step-3);
}

.page-intro {
  margin: var(--s-2) 0 0;
  color: var(--muted);
  font-size: var(--step--1);
  max-width: 46ch;
}

/* The swipe hint is a caption, not a peer of the heading. It was
   wrapping to two lines and crowding the title. */
.home-panel-heading {
  align-items: baseline;
  gap: var(--s-4);
}

.home-swipe-hint {
  flex: 0 0 auto;
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--step--2);
  line-height: 1.3;
  color: var(--faint);
  text-align: right;
  max-width: 8.5rem;
}

/* Bottom navigation */
.bottom-navigation {
  background: color-mix(in srgb, var(--background) 88%, transparent);
  backdrop-filter: saturate(1.4) blur(18px);
  -webkit-backdrop-filter: saturate(1.4) blur(18px);
  border-top: 1px solid var(--border);
}

.nav-button {
  gap: 3px;
  color: var(--faint);
  font-family: var(--font-body);
  font-size: var(--step--2);
  font-weight: 500;
  letter-spacing: 0.005em;
  background: none;
  border: 0;
  transition: color var(--dur-2) var(--ease);
}

.nav-button .nav-icon {
  display: grid;
  place-items: center;
  width: 26px;
  height: 26px;
  font-size: 0;
}

.nav-button .nav-icon svg {
  width: 22px;
  height: 22px;
  stroke: currentColor;
  fill: none;
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
  transition: transform var(--dur-2) var(--ease-out);
}

.nav-button.active-nav {
  color: var(--accent);
}

.nav-button.active-nav .nav-icon svg {
  transform: translateY(-1px);
}

.nav-button:active .nav-icon svg {
  transform: scale(0.88);
}

/* ---------------------------------------------------------
   4. SURFACES
   --------------------------------------------------------- */

.stat-card,
.settings-card,
.empty-card,
.show-card,
.home-episode-card,
.stats-list-card,
.watch-providers-section {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-card);
}

.empty-card {
  border-style: dashed;
  border-color: var(--border-strong);
  box-shadow: none;
  padding: var(--s-7) var(--s-5);
  text-align: center;
}

.empty-card h3 {
  font-size: var(--step-1);
  margin-bottom: var(--s-2);
}

.empty-card p {
  color: var(--muted);
  font-size: var(--step--1);
  margin: 0 auto;
  max-width: 34ch;
}

/* Empty states are an invitation to act, so they carry the action. */
.empty-card .empty-action {
  margin-top: var(--s-5);
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  padding: 11px 20px;
  border: 0;
  border-radius: var(--r-full);
  background: var(--accent);
  color: var(--accent-contrast);
  font-family: var(--font-body);
  font-weight: 600;
  font-size: var(--step--1);
  transition: transform var(--dur-1) var(--ease),
              background-color var(--dur-2) var(--ease);
}

.empty-card .empty-action:hover {
  background: var(--accent-dark);
  transform: translateY(-1px);
}

.empty-card .empty-action:active {
  transform: scale(0.97);
}

/* ---------------------------------------------------------
   5. CONTROLS
   --------------------------------------------------------- */

.primary-button {
  border-radius: var(--r-full);
  background: var(--accent);
  color: var(--accent-contrast);
  font-family: var(--font-body);
  font-weight: 600;
  letter-spacing: -0.005em;
  border: 0;
  transition: transform var(--dur-1) var(--ease),
              background-color var(--dur-2) var(--ease);
}

.primary-button:hover {
  background: var(--accent-dark);
}

.primary-button:active {
  transform: scale(0.985);
}

.movie-secondary-button,
.back-button,
.details-back-button {
  border-radius: var(--r-full);
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text);
  font-family: var(--font-body);
  font-weight: 600;
  font-size: var(--step--1);
  /*
   * `color` belongs here because the :hover rule below changes it. It was
   * omitted, so the border eased to the accent over 160ms while the label
   * snapped to it on frame one — the two halves of one hover arriving at
   * different times, which reads as a glitch rather than a transition.
   */
  transition: border-color var(--dur-2) var(--ease),
              color var(--dur-2) var(--ease),
              transform var(--dur-1) var(--ease);
}

.movie-secondary-button:hover,
.back-button:hover,
.details-back-button:hover {
  border-color: var(--accent);
  color: var(--accent);
}

/* Every pressable in this family gets the same press feedback; the movie
   variant was the only one without it. */
.movie-secondary-button:active,
.back-button:active,
.details-back-button:active {
  transform: scale(0.97);
}

input[type="text"],
input[type="search"],
input[type="email"],
input[type="password"],
select,
textarea {
  border-radius: var(--r-md);
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text);
  font-family: var(--font-body);
  transition: border-color var(--dur-2) var(--ease),
              box-shadow var(--dur-2) var(--ease);
}

input:focus,
select:focus,
textarea:focus {
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-ring);
  outline: none;
}

::placeholder {
  color: var(--faint);
}

/* Segmented control */
.search-type-toggle {
  background: var(--surface-light);
  border: 1px solid var(--border);
  border-radius: var(--r-full);
  padding: 4px;
}

.search-type-toggle button {
  border-radius: var(--r-full);
  font-family: var(--font-body);
  font-weight: 600;
  font-size: var(--step--1);
  color: var(--muted);
  background: transparent;
  border: 0;
  /* box-shadow belongs here: the active pill below raises one, and without
     it the shadow popped in on frame one while the colour eased over 160ms. */
  transition: color var(--dur-2) var(--ease),
              background-color var(--dur-2) var(--ease),
              box-shadow var(--dur-2) var(--ease);
}

/*
 * Swapping TV <-> Movies re-points several panels through the `hidden`
 * attribute, which is display:none and therefore cannot be transitioned —
 * the whole content area simply teleported.
 *
 * The incoming content block gets one short entrance instead. Deliberately
 * ONE element, not the four panels that change: animating each of them
 * would recreate the simultaneous-child-animation cost that made mobile
 * tab switches stutter. The outgoing panel is not faded out because the two
 * cannot share layout space without absolute positioning, and a gap opening
 * mid-swap reads far worse than a clean replace.
 */
.search-panel-enter {
  animation: search-panel-in var(--dur-state) var(--ease-out) both;
}

@keyframes search-panel-in {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: translateY(0); }
}

@media (prefers-reduced-motion: reduce) {
  /* Gentler, not absent: the fade still signals that content changed. */
  .search-panel-enter {
    animation: search-panel-fade var(--dur-press) linear both;
  }

  @keyframes search-panel-fade {
    from { opacity: 0; }
    to   { opacity: 1; }
  }
}

.search-type-toggle button[aria-pressed="true"],
.search-type-toggle button.active,
.search-type-toggle button.is-active {
  background: var(--surface);
  color: var(--accent);
  box-shadow: var(--shadow-card);
}

/* Horizontal chip rails get an edge fade so it reads as scrollable
   instead of looking like a chip was clipped by accident. */
.chip-rail-mask {
  position: relative;
}

.chip-rail-mask::after {
  content: "";
  position: absolute;
  inset: 0 0 0 auto;
  width: 40px;
  pointer-events: none;
  background: linear-gradient(
    to right,
    transparent,
    var(--background)
  );
}

/* Group labels inside the merged movie library. The want-to-watch and
   watched grids are stacked, so each needs its own quiet heading. */
.movie-group-label {
  margin: var(--s-6) 0 var(--s-3);
}

.movie-group-label:first-child {
  margin-top: var(--s-2);
}

/* ---------------------------------------------------------
   6. POSTERS
   --------------------------------------------------------- */

.library-poster-card {
  border-radius: var(--r-md);
  overflow: hidden;
  background: linear-gradient(150deg, var(--poster-a), var(--poster-b));
  border: 1px solid var(--border);
  box-shadow: var(--shadow-card);
  transition: transform var(--dur-2) var(--ease-out),
              box-shadow var(--dur-2) var(--ease-out);
}

.library-poster-card:hover {
  transform: translateY(-3px);
  box-shadow: var(--shadow-raised);
}

.library-poster-card:active {
  transform: scale(0.975);
}

/* ---------------------------------------------------------
   7. SIGNATURE — the season strip
   A season rendered as a ribbon of episode cells: filled means
   watched. It replaces scrolling 23 identical rows to find your
   place, shows the shape of a season at a glance, and is the one
   element the app is meant to be remembered by.
   --------------------------------------------------------- */

/*
 * Grid, not a ribbon. The first pass drew 13px-wide cells, which
 * style.css:3349 already overrode to 44px tall (the app's own mobile
 * tap-target floor) and which failed WCAG 2.5.8's 24px minimum in the
 * narrow dimension anyway. Sizing the cells to that existing 44px
 * convention instead of fighting it makes every episode tappable and
 * leaves room for its number, so the grid reads as a season at a
 * glance AND is usable with a thumb.
 */
.season-strip {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(42px, 1fr));
  gap: var(--s-2);
  padding: var(--s-4) 0 var(--s-2);
}

.season-strip-cell {
  min-height: 44px;
  padding: 0;
  border: 1px solid var(--strip-empty-border);
  border-radius: var(--r-sm);
  background: var(--strip-empty);
  color: var(--muted);
  font-family: var(--font-mono);
  font-size: var(--step--1);
  font-feature-settings: "tnum" 1;
  transition: background-color var(--dur-1) var(--ease),
              border-color var(--dur-1) var(--ease),
              transform var(--dur-1) var(--ease);
}

.season-strip-cell[data-watched="true"] {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-contrast);
  font-weight: 500;
}

.season-strip-cell[data-current="true"] {
  border-color: var(--accent);
  box-shadow: 0 0 0 2px var(--accent-ring);
}

.season-strip-cell:hover {
  transform: translateY(-1px);
  border-color: var(--accent);
}

.season-strip-cell:active {
  transform: scale(0.94);
}

.season-strip-caption {
  display: flex;
  justify-content: space-between;
  gap: var(--s-3);
  font-size: var(--step--2);
  color: var(--muted);
  padding-bottom: var(--s-4);
  border-bottom: 1px solid var(--border);
  margin-bottom: var(--s-4);
}

/* ---------------------------------------------------------
   8. STATS
   --------------------------------------------------------- */

.stat-card {
  padding: var(--s-4) var(--s-5);
  text-align: left;
}

.stat-label {
  font-family: var(--font-mono);
  font-size: var(--step--2);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--faint);
  margin: 0 0 var(--s-2);
}

.stat-number {
  font-size: var(--step-3);
  font-weight: 500;
  color: var(--accent);
  margin: 0;
}

/* A bar chart drawn in plain CSS — no library, no network. */
.chart {
  display: grid;
  gap: var(--s-3);
  padding: var(--s-2) 0 0;
}

.chart-row {
  display: grid;
  grid-template-columns: 6.5rem 1fr auto;
  align-items: center;
  gap: var(--s-3);
}

.chart-key {
  font-size: var(--step--1);
  color: var(--muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.chart-track {
  height: 10px;
  border-radius: var(--r-full);
  background: var(--surface-light);
  overflow: hidden;
}

.chart-fill {
  /*
   * The markup builds this as a <span>, so it defaulted to display:inline —
   * and width, height, and transform are all ignored on a non-replaced
   * inline box. Every bar in both stats charts was laid out 0x0 with a
   * perfectly correct gradient and inline width that could never paint.
   * The track escaped this only because it is a grid item, which blockifies.
   */
  display: block;
  height: 100%;
  border-radius: var(--r-full);
  background: linear-gradient(90deg, var(--accent-dark), var(--accent));
  transform-origin: left center;
  animation: chart-grow var(--dur-value) var(--ease-value) both;
}

.chart-value {
  font-family: var(--font-mono);
  font-size: var(--step--1);
  color: var(--text);
  font-feature-settings: "tnum" 1;
}

@keyframes chart-grow {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

/* Weekday heatmap */
.heatmap {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  gap: var(--s-2);
  padding-top: var(--s-2);
}

.heatmap-day {
  display: grid;
  gap: var(--s-2);
  justify-items: center;
}

.heatmap-swatch {
  width: 100%;
  aspect-ratio: 1;
  border-radius: var(--r-sm);
  background: var(--accent);
  box-shadow: inset 0 0 0 1px var(--border);
}

.heatmap-label {
  font-family: var(--font-mono);
  font-size: var(--step--2);
  color: var(--faint);
}

/* ---------------------------------------------------------
   9. SETTINGS
   --------------------------------------------------------- */

.settings-card {
  padding: var(--s-5);
  margin-bottom: var(--s-4);
}

.settings-card h3 {
  font-size: var(--step-1);
}

.settings-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-4);
  padding: var(--s-4) 0;
  border-bottom: 1px solid var(--border);
}

.settings-row:last-child {
  border-bottom: 0;
  padding-bottom: 0;
}

.settings-row-copy p {
  margin: 2px 0 0;
  color: var(--muted);
  font-size: var(--step--1);
}

.settings-row-label {
  font-weight: 600;
  color: var(--text);
}

/* Theme picker */
.theme-picker {
  display: inline-flex;
  gap: 2px;
  padding: 3px;
  border-radius: var(--r-full);
  background: var(--surface-light);
  border: 1px solid var(--border);
}

.theme-option {
  padding: 7px 13px;
  border: 0;
  border-radius: var(--r-full);
  background: transparent;
  color: var(--muted);
  font-family: var(--font-body);
  font-size: var(--step--1);
  font-weight: 600;
  transition: background-color var(--dur-2) var(--ease),
              color var(--dur-2) var(--ease);
}

.theme-option[aria-pressed="true"] {
  background: var(--surface);
  color: var(--accent);
  box-shadow: var(--shadow-card);
}

/* ---------------------------------------------------------
   10. SKELETONS
   --------------------------------------------------------- */

.skeleton {
  position: relative;
  overflow: hidden;
  background: var(--surface-light);
  border-radius: var(--r-md);
}

.skeleton::after {
  content: "";
  position: absolute;
  inset: 0;
  transform: translateX(-100%);
  background: linear-gradient(
    90deg,
    transparent,
    color-mix(in srgb, var(--surface) 70%, transparent),
    transparent
  );
  animation: skeleton-sweep var(--dur-ambient) infinite;
}

.skeleton-poster {
  aspect-ratio: 2 / 3;
  width: 100%;
}

@keyframes skeleton-sweep {
  to { transform: translateX(100%); }
}

/* ---------------------------------------------------------
   11. AUTH SCREEN
   --------------------------------------------------------- */

.tv-auth-gate {
  background: var(--background);
}

.tv-login-card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--r-xl);
  box-shadow: var(--shadow-raised);
}

.tv-login-card h1 {
  font-size: var(--step-3);
  color: var(--text-strong);
}

.tv-login-card p {
  color: var(--muted);
}

.tv-login-card label {
  color: var(--text);
  font-size: var(--step--1);
}

.tv-login-card button {
  border-radius: var(--r-full);
  background: var(--accent);
  color: var(--accent-contrast);
  font-family: var(--font-body);
}

.tv-login-card button.tv-guest-button {
  background: transparent;
  border: 1px solid var(--border);
  color: var(--text);
}

.tv-login-card button.tv-guest-button:hover {
  border-color: var(--accent);
  color: var(--accent);
}

.tv-login-card .tv-guest-note {
  color: var(--faint);
  font-size: var(--step--2);
}

/* ---------------------------------------------------------
   12. WIDE SCREENS
   The shell was a 480px column with a nav bar that ran the full
   1440px, which read as a broken layout. The nav now tracks the
   column it belongs to.
   --------------------------------------------------------- */

@media (min-width: 900px) {
  .bottom-navigation {
    left: 50%;
    transform: translateX(-50%);
    width: min(520px, 100%);
    border: 1px solid var(--border);
    border-radius: var(--r-xl);
    bottom: var(--s-5);
    box-shadow: var(--shadow-raised);
  }

  .top-header {
    max-width: 480px;
    margin: 0 auto;
  }
}

/* ---------------------------------------------------------
   13. MOTION PREFERENCES
   --------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
  }
}

/* ---------------------------------------------------------
   14. MOTION
   Five moments, each earning its place. Everything else in the
   app is deliberately left still: tab switches, episode-row
   toggles and season-cell taps happen dozens of times a day,
   and motion on a primary verb becomes friction fast.

   Transform and opacity only, so none of this touches layout.
   --------------------------------------------------------- */

/* 1. Swipe snap-back.
   finishHomeSwipe() clears the inline transform when a drag falls
   short of the threshold, but .is-swiping (transition:none) has
   just been removed and the base card declares no transition — so
   the card teleported home. A release should decelerate. */
.home-episode-card {
  transition: transform var(--dur-3) var(--ease-out);
}

.home-episode-card.is-swiping {
  transition: none;
}

/* 2. The undo toast. It appeared and vanished instantly, with
   hardcoded colours that ignored the theme. It now enters from the
   edge it lives on, so the motion says where it came from. */
.home-swipe-undo {
  border-color: var(--positive);
  background: color-mix(in srgb, var(--positive) 18%, var(--surface));
  color: var(--text);
  animation: toast-in var(--dur-surface) var(--ease-surface) both;
}

@keyframes toast-in {
  from { opacity: 0; transform: translateY(120%); }
  to   { opacity: 1; transform: translateY(0); }
}

/* 3. Action popovers grow from the control that opened them,
   rather than materialising unanchored. */
.details-action-menu[open] > *:not(summary) {
  transform-origin: top right;
  animation: popover-in var(--dur-surface) var(--ease-surface) both;
}

@keyframes popover-in {
  from { opacity: 0; transform: scale(0.96); }
  to   { opacity: 1; transform: scale(1); }
}

/* 4. Finishing a show is rare and worth a beat — this is where the
   delight budget goes. Applied by JS when a show completes. */
.season-strip.is-complete .season-strip-cell {
  animation: cell-settle 260ms var(--ease-out) both;
  animation-delay: calc(var(--cell-index, 0) * 18ms);
}

@keyframes cell-settle {
  0%   { transform: scale(1); }
  55%  { transform: scale(1.12); }
  100% { transform: scale(1); }
}

/* 5. Reduced motion: gentler, not gone. The distance and scale come
   out; a short fade stays, so state changes are still bridged rather
   than teleporting. Sits after the global block so it wins. */
@media (prefers-reduced-motion: reduce) {
  .home-swipe-undo,
  .details-action-menu[open] > *:not(summary),
  .tv-login-card {
    animation: reduced-fade 120ms linear both !important;
  }

  .season-strip.is-complete .season-strip-cell {
    animation: none !important;
  }

  @keyframes reduced-fade {
    from { opacity: 0; }
    to   { opacity: 1; }
  }
}

/* ---------------------------------------------------------
   15. FIXES TO INHERITED RULES
   --------------------------------------------------------- */

/*
 * .library-poster-frame is a <span>, so it computes to display:inline —
 * and aspect-ratio has no effect on a non-replaced inline box. The
 * `aspect-ratio: 2/3` at style.css:1194 was therefore silently doing
 * nothing, and every movie poster collapsed to its image's own height.
 * (Symptom: getComputedStyle().height returns "auto" on a laid-out box.)
 */
.library-poster-frame {
  display: block;
}

/* The new home movies section sat flush against the last Up Next card. */
#home-movies-section {
  margin-top: var(--s-7);
}

/*
 * The poster progress track was a hardcoded rgba(255,255,255,.58) —
 * a bright white band across the foot of every poster, which reads as
 * glare on the dark theme rather than as an empty track. The fill also
 * ended in a literal #b95d69 that ignored the accent in dark mode.
 */
.library-poster-progress {
  background: color-mix(in srgb, var(--text) 22%, transparent);
  box-shadow: none;
}

.library-poster-progress-fill {
  background: linear-gradient(90deg, var(--accent-dark), var(--accent));
}

/* ---------------------------------------------------------
   16. MATERIALS
   Translucent chrome as a floating functional layer, with
   content scrolling underneath rather than beside it.

   Three weights. Bigger surfaces read as thicker: more blur and
   a deeper shadow. A light translucent surface is never stacked
   on another — legibility collapses when you do.
   --------------------------------------------------------- */

:root {
  --glass-thin: blur(14px) saturate(180%);
  --glass-regular: blur(22px) saturate(180%);
  --glass-thick: blur(34px) saturate(200%);

  /* The tint the blur sits on. Kept separate from --surface so the
     material can be retuned without touching solid surfaces. */
  --glass-tint: rgba(255, 255, 255, 0.62);
  --glass-edge: rgba(255, 255, 255, 0.55);
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --glass-tint: rgba(26, 23, 28, 0.62);
    --glass-edge: rgba(255, 255, 255, 0.09);
  }
}

:root[data-theme="dark"] {
  --glass-tint: rgba(26, 23, 28, 0.62);
  --glass-edge: rgba(255, 255, 255, 0.09);
}

.top-header,
.bottom-navigation {
  background: var(--glass-tint);
  backdrop-filter: var(--glass-regular);
  -webkit-backdrop-filter: var(--glass-regular);
}

/* A bright hairline on the leading edge reads as light catching the
   material. It is the difference between "blurred div" and "glass". */
.bottom-navigation {
  border-top: 1px solid var(--glass-edge);
}

.top-header {
  border-bottom: 1px solid transparent;
}

/*
 * Scroll edge effect instead of a hard divider. A 1px rule under a
 * sticky header states a boundary; a short fade lets the content
 * dissolve into the chrome, which is what a real material does.
 */
.top-header::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: 100%;
  height: 18px;
  pointer-events: none;
  opacity: 0;
  background: linear-gradient(
    to bottom,
    var(--background),
    transparent
  );
  transition: opacity var(--dur-2) var(--ease);
}

.top-header.is-stuck {
  border-bottom-color: transparent;
}

.top-header.is-stuck::after {
  opacity: 0.9;
}

/*
 * Vibrancy. Text over a blurred surface loses apparent contrast as
 * whatever is behind it changes, so chrome type gets a touch more
 * weight and tracking rather than flat grey.
 */
.bottom-navigation .nav-button {
  letter-spacing: 0.01em;
}

.bottom-navigation .nav-button.active-nav {
  font-weight: 600;
}

/* Popovers and the login card are glass too, but thicker — a larger
   surface should read as a heavier piece of material. */
.details-action-menu[open] > *:not(summary),
.tv-login-card {
  background: var(--glass-tint);
  backdrop-filter: var(--glass-thick);
  -webkit-backdrop-filter: var(--glass-thick);
  border: 1px solid var(--glass-edge);
}

/*
 * Materialise, don't just fade. Animating blur and scale together
 * makes the surface read as a material arriving rather than an
 * image being cross-faded in.
 */
@keyframes glass-in {
  from {
    opacity: 0;
    transform: scale(0.965);
    backdrop-filter: blur(0px) saturate(100%);
    -webkit-backdrop-filter: blur(0px) saturate(100%);
  }
  to {
    opacity: 1;
    transform: scale(1);
    backdrop-filter: var(--glass-thick);
    -webkit-backdrop-filter: var(--glass-thick);
  }
}

.details-action-menu[open] > *:not(summary) {
  animation: glass-in var(--dur-surface) var(--ease-surface) both;
}

/*
 * The login card is glass too (this section, above) but never got the
 * same entrance — it just appeared. It replays whenever the auth gate
 * is unhidden (sign-out), same treatment as the popover.
 */
.tv-login-card {
  animation: glass-in var(--dur-surface) var(--ease-surface) both;
}

/* ---------------------------------------------------------
   17. ACCESSIBILITY FALLBACKS FOR MATERIALS
   Translucency is a preference, not a given. Both of these are
   routinely skipped, and both make the UI unusable for the people
   who set them.
   --------------------------------------------------------- */

@media (prefers-reduced-transparency: reduce) {
  :root {
    --glass-tint: var(--surface);
  }

  .top-header,
  .bottom-navigation,
  .details-action-menu[open] > *:not(summary),
  .tv-login-card {
    background: var(--surface);
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }

  .details-action-menu[open] > *:not(summary) {
    animation: popover-in var(--dur-surface) var(--ease-surface) both;
  }
}

@media (prefers-contrast: more) {
  .top-header,
  .bottom-navigation,
  .details-action-menu[open] > *:not(summary),
  .tv-login-card {
    background: var(--surface);
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
    border-color: var(--border-strong);
  }
}

/*
 * Vibrancy correction. Nav labels sit over whatever is scrolling
 * underneath, so their effective background changes constantly. --faint
 * has the least headroom of the ink tokens (4.5:1 against a KNOWN
 * ground, less over a bright poster), so chrome type steps up to
 * --muted and the material carrying it gets a denser tint. Contrast
 * over a variable backdrop has to be budgeted for the worst case, not
 * the average one.
 */
.bottom-navigation {
  --glass-tint-nav: color-mix(in srgb, var(--background) 82%, transparent);
  background: var(--glass-tint-nav);
}

.bottom-navigation .nav-button {
  color: var(--muted);
}

.bottom-navigation .nav-button.active-nav {
  color: var(--accent);
}

/* ---------------------------------------------------------
   18. HIERARCHICAL PAGE TRANSITIONS
   Push travels forward (new view in from the right, old view
   recedes left); pop mirrors it exactly. Symmetry is the point —
   a view that arrives one way and leaves another reads as two
   unrelated events instead of one reversible move.

   Only applied to push/pop. Tab-to-tab stays instant.
   --------------------------------------------------------- */

@media (prefers-reduced-motion: no-preference) {
  ::view-transition-old(root),
  ::view-transition-new(root) {
    animation-duration: var(--dur-page);
    animation-timing-function: var(--ease-page);
    mix-blend-mode: normal;
  }

  /* --- PUSH: going deeper --- */
  :root[data-nav-direction="push"]::view-transition-old(root) {
    animation-name: page-recede;
  }

  :root[data-nav-direction="push"]::view-transition-new(root) {
    animation-name: page-enter-right;
  }

  /* --- POP: coming back, the exact inverse --- */
  :root[data-nav-direction="pop"]::view-transition-old(root) {
    animation-name: page-exit-right;
  }

  :root[data-nav-direction="pop"]::view-transition-new(root) {
    animation-name: page-return;
  }

  @keyframes page-enter-right {
    from { transform: translateX(9%); opacity: 0; }
    to   { transform: translateX(0);   opacity: 1; }
  }

  /*
   * The outgoing view slides a shorter distance than the incoming one
   * and dims slightly, rather than sliding fully off. It reads as
   * being pushed back in space and left behind, which is what makes
   * the hierarchy legible.
   */
  @keyframes page-recede {
    from { transform: translateX(0);   opacity: 1; }
    to   { transform: translateX(-4%); opacity: 0.4; }
  }

  @keyframes page-exit-right {
    from { transform: translateX(0);  opacity: 1; }
    to   { transform: translateX(9%); opacity: 0; }
  }

  @keyframes page-return {
    from { transform: translateX(-4%); opacity: 0.4; }
    to   { transform: translateX(0);   opacity: 1; }
  }
}

/* The chrome is a fixed frame; it should not travel with the content. */
.top-header,
.bottom-navigation {
  view-transition-name: none;
}

/* ---------------------------------------------------------
   19. MOTION TOKENS
   Named by ROLE, not by number. Before this, the stylesheet
   carried five different durations (160ms, .18s, .2s, .22s,
   .25s) for what are the same class of interaction — which is
   what makes motion feel arbitrary rather than designed.

   Duration and easing are paired per role and used as a unit,
   so a component picks "this is a press" rather than picking a
   number and a curve independently and getting an odd hybrid.

   Speed follows how often a thing happens: the more frequently
   a user sees it, the faster it must be.
   --------------------------------------------------------- */

:root {
  /* Tactile feedback. Must feel instantaneous or directness
     "falls off a cliff" — this is the floor everything else
     is built on. */
  --dur-press: 120ms;
  --ease-press: cubic-bezier(0.23, 1, 0.32, 1);

  /* Hover and focus affordances. Seen constantly, so quick. */
  --dur-hover: 160ms;
  --ease-hover: cubic-bezier(0.22, 0.61, 0.36, 1);

  /* A control changing state: chips, toggles, selection. */
  --dur-state: 200ms;
  --ease-state: cubic-bezier(0.22, 0.61, 0.36, 1);

  /* Surfaces arriving or moving: cards, popovers, sheets.
     Decelerating, because a surface settling into place should
     arrive quickly and land softly. */
  --dur-surface: 340ms;
  --ease-surface: cubic-bezier(0.32, 0.72, 0, 1);

  /* Hierarchical page pushes and pops. */
  --dur-page: 450ms;
  --ease-page: cubic-bezier(0.32, 0.72, 0, 1);

  /* Theme changes. Slowest on purpose — a deliberate, rare act,
     and an abrupt brightness jump is uncomfortable. */
  --dur-theme: 320ms;
  --ease-theme: cubic-bezier(0.22, 0.61, 0.36, 1);

  /* Progress and value changes that are read rather than felt. */
  --dur-value: 420ms;
  --ease-value: cubic-bezier(0.16, 1, 0.3, 1);

  /* Ambient loops (skeleton shimmer). Deliberately far slower than
     any interaction: a loop near 0.2Hz is uncomfortable, and anything
     quick enough to notice competes with the content it stands in for. */
  --dur-ambient: 1400ms;
}


/* ---------------------------------------------------------
   20. TOUCH: NO STICKY HOVER
   A touch device fires :hover on tap and never fires the
   matching pointer-out, so the hover state STAYS until the
   user taps something else. On a mobile-first app that means
   every card you tap stays lifted and outlined.

   Measured: 26 hover rules applied a transform or an accent
   border, none of them gated behind a pointer query.

   Scoped with :not(:active) so press feedback still works, and
   to `hover: none` only, so desktop is untouched.
   --------------------------------------------------------- */

@media (hover: none) {
  .back-button:hover:not(:active),
  .details-action-menu > summary:hover:not(:active),
  .details-back-button:hover:not(:active),
  .details-list-chip:hover:not(:active),
  .discovery-card:hover:not(:active) .discovery-poster-frame,
  .discovery-filter:hover:not(:active),
  .empty-card .empty-action:hover:not(:active),
  .episode-card:hover:not(:active),
  .episode-rewatch-undo:hover:not(:active),
  .home-check-button:hover:not(:active),
  .home-episode-card:hover:not(:active),
  .library-check-button:hover:not(:active),
  .library-delete-button:hover:not(:active),
  .library-poster-card:hover:not(:active),
  .library-undo-button:hover:not(:active),
  .movie-poster-card:hover:not(:active),
  .movie-secondary-button:hover:not(:active),
  .profile-circle:hover:not(:active),
  .search-result-card:hover:not(:active),
  .season-strip-cell:hover:not(:active),
  .season-watch-button-complete:hover:not(:active),
  .secondary-button:hover:not(:active),
  .stat-card[data-open-activity]:hover:not(:active),
  .stats-list-card:hover:not(:active),
  .tv-login-card button.tv-guest-button:hover:not(:active),
  .watched-episode-button:hover:not(:active) {
    transform: none;
  }
}

/* ---------------------------------------------------------
   21. ACCESSIBILITY FIXES (measured audit)
   --------------------------------------------------------- */

/*
 * The focus ring was painted at 28% alpha (style.css:3120), which beat
 * this file's own 2px accent ring on specificity and so won everywhere.
 * Measured against its adjacent background it came out at 1.09:1 in dark
 * and 1.68:1 in light — WCAG 1.4.11 needs 3:1. A ring at 28% alpha can
 * never reach 3:1 on most backgrounds; the alpha is the bug, not the
 * colour. Matched selector-for-selector so this wins on order.
 */
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
a:focus-visible,
[tabindex]:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 2px;
  border-radius: var(--r-sm);
}

/*
 * The bottom nav's own vibrancy rule sat AFTER the reduced-transparency
 * and high-contrast blocks at equal specificity, so it silently undid
 * them: the blur was removed but the background stayed 82% translucent.
 * These re-state the fallback with enough specificity to win.
 */
@media (prefers-reduced-transparency: reduce) {
  .bottom-navigation,
  nav.bottom-navigation {
    background: var(--surface);
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }
}

@media (prefers-contrast: more) {
  .bottom-navigation,
  nav.bottom-navigation {
    background: var(--surface);
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
    border-top-color: var(--border-strong);
  }
}

/*
 * Nav labels measured 3.39:1 in light over a bright poster scrolled
 * beneath the glass — the earlier "vibrancy correction" moved them to
 * --muted, which clears 4.5:1 against a KNOWN background but not against
 * real artwork. Two changes: a denser tint so the composite varies less,
 * and full --text weight for inactive labels.
 */
.bottom-navigation {
  --glass-tint-nav: color-mix(in srgb, var(--background) 92%, transparent);
}

.bottom-navigation .nav-button {
  color: var(--text);
}

/* ---------------------------------------------------------
   22. DESKTOP LAYOUT (>=1040px)
   Below this the shell is unchanged: full-width bottom bar under
   900px, the floating pill from section 12 between 900-1039px.

   At 1440x900 the previous wide-screen pass repositioned the nav
   but left the content a ~440px column adrift in the rest of the
   viewport — every page read as unfinished. This block turns the
   SAME .bottom-navigation element into a persistent left rail and
   lets the content column actually use the reclaimed width, ~1100px
   rather than a centred phone column.
   --------------------------------------------------------- */

@media (min-width: 1040px) {
  :root {
    --rail-width: 104px;
    --content-max: 1100px;
  }

  /* The rail. Same markup as the bottom bar — icon-over-label was
     already the shape (.nav-button is flex-direction: column at
     every width), so turning it vertical is a position + axis
     change, not a rebuild. */
  .bottom-navigation {
    top: 0;
    right: auto;
    bottom: 0;
    left: 0;
    transform: none;
    width: var(--rail-width);
    height: 100vh;
    flex-direction: column;
    align-items: stretch;
    justify-content: flex-start;
    gap: var(--s-2);
    padding: var(--s-8) var(--s-3) var(--s-6);
    border: 0;
    border-right: 1px solid var(--glass-edge);
    border-radius: 0;
    box-shadow: none;
  }

  .nav-button {
    width: 100%;
    max-width: none;
    flex: 0 0 auto;
    padding: var(--s-3) var(--s-2);
    border-radius: var(--r-md);
    transition: background-color var(--dur-hover) var(--ease-hover),
                color var(--dur-hover) var(--ease-hover);
  }

  .nav-button.active-nav {
    background: var(--accent-light);
  }

  /* Content column: offset to clear the rail, capped well short of
     full-bleed rather than left centred at phone width. */
  .app-container {
    /*
     * The width must EXCLUDE the rail and both gutters, not be 100% of
     * the viewport with a margin added on top — otherwise the column
     * plus its own offset exceeds the viewport. Measured at exactly
     * 1040px (the breakpoint edge, where there is no slack): 1040px
     * container + 104px rail + gutter = horizontal overflow. Wider
     * viewports hid it because max-width capped the column first.
     */
    width: calc(100% - var(--rail-width) - var(--s-7) * 2);
    max-width: var(--content-max);
    margin-left: calc(var(--rail-width) + var(--s-7));
    margin-right: auto;
    padding-bottom: var(--s-7);
  }

  main {
    padding-bottom: var(--s-7);
  }

  /* The header tracks the content column, not the viewport. */
  .top-header {
    max-width: 100%;
    margin: 0;
  }

  /* Show details: poster + metadata + progress stay a fixed-width
     left column; the season area gets the rest of the width, so the
     season grid reflows into more columns via its own existing
     auto-fill track rather than staying at phone density. */
  #show-details-content {
    display: grid;
    grid-template-columns: minmax(240px, 280px) 1fr;
    align-items: start;
    gap: var(--s-6) var(--s-7);
  }

  .details-header {
    grid-column: 1;
    grid-row: 1;
    margin-bottom: 0;
  }

  .watch-providers-section {
    grid-column: 1;
    grid-row: 2;
  }

  .seasons-section {
    grid-column: 2;
    grid-row: 1 / span 2;
    margin-top: 0;
  }

  .details-cast-section {
    grid-column: 2;
    grid-row: 3;
  }

  /* Stats: the horizontal scroll strip becomes a real dashboard grid. */
  .statistics-scroll-container {
    overflow: visible;
  }

  .statistics-row {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    width: auto;
  }

  .statistics-row .stat-card,
  .statistics-row .watch-time-card {
    width: auto;
    min-width: 0;
    max-width: none;
    height: auto;
    min-height: 96px;
    max-height: none;
    flex: initial;
  }

  .stats-insights-section {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
    align-items: start;
    gap: var(--s-5);
  }

  .stats-insights-section .settings-card {
    margin-bottom: 0;
  }

  .stats-lists-grid {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }

  /* Library, search and movie grids: more columns for the width
     instead of phone density stretched thin. */
  #shows-list,
  .movie-poster-grid,
  .discovery-grid {
    grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  }
}

/*
 * The rail moves its hairline from border-top to border-right, but
 * section 21's prefers-contrast fallback only upgrades border-top-color
 * (written for the bottom bar's edge) — so at desktop width the rail's
 * visible edge stayed at the low-contrast --glass-edge even with high
 * contrast requested. Same fix, same side the rail actually has a border.
 */
@media (min-width: 1040px) and (prefers-contrast: more) {
  .bottom-navigation {
    border-right-color: var(--border-strong);
  }
}

/*
 * Rail item hover. Gated on (hover: hover) rather than living inside
 * the plain min-width block: a touch-capable device that also matches
 * this width (an iPad or a Surface in landscape) fires :hover on tap
 * with no matching pointer-out, which is exactly the stuck-highlight
 * bug section 20 exists to prevent for every other hover rule in the
 * file. This is the one hover added after that section, so it gets
 * the same treatment at the source instead of joining that list.
 */
@media (min-width: 1040px) and (hover: hover) {
  .nav-button:hover:not(.active-nav) {
    background: color-mix(in srgb, var(--text) 6%, transparent);
  }
}

/* Desktop details: the left column was narrow enough that a three-word
   title broke onto three lines. Give it room and let the heading balance. */
@media (min-width: 1040px) {
  #show-details-content {
    grid-template-columns: minmax(320px, 0.85fr) minmax(0, 1.6fr);
  }

  .details-header h2 {
    font-size: var(--step-2);
    text-wrap: balance;
  }

  .details-information {
    min-width: 0;
  }
}

/* ---------------------------------------------------------
   23. SHARED-ELEMENT POSTER TRANSITIONS
   motion-enhance.js tags the clicked library/search poster and
   the details-page poster with a matching view-transition-name
   right before push (and the reverse on pop), then clears it
   once the transition settles. This just tunes the resulting
   group's timing to match the page transition it rides along
   with — the browser's own cross-fade + geometry interpolation
   does the actual shape/position morph, no keyframes needed.

   `*` is the spec's wildcard match for a dynamically-assigned
   name, which is what's needed here since the name is derived
   per-show/movie at runtime. Section 18's root page pseudo keeps
   its own bespoke slide — a named selector always beats `*` on
   specificity, so there's no conflict between the two.
   --------------------------------------------------------- */

@media (prefers-reduced-motion: no-preference) {
  ::view-transition-group(*) {
    animation-duration: var(--dur-page);
    animation-timing-function: var(--ease-page);
  }

  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation-duration: var(--dur-page);
    animation-timing-function: var(--ease-page);
  }
}

/* ---------------------------------------------------------
   24. ENTRANCE MOTION — GRIDS AND RESULTS
   ~50 innerHTML replacements across the app populate these
   cards with no enter transition at all — they simply appear.
   @starting-style is used instead of @keyframes so a rapid
   re-render (typing in search, switching a filter) retargets
   the in-flight transition instead of restarting a keyframe
   from frame zero, which is what makes repeated re-renders feel
   twitchy rather than smooth.

   Gated on the .tv-entrance MARKER CLASS rather than the card's
   own class, and that matters: .page/.active-page (section 3) is
   a plain display:none/block toggle, and @starting-style retriggers
   on ANY display:none -> block reveal of a persisting element, not
   just a genuinely new one. Keying off the bare card classes was
   measured to replay the whole grid's entrance every time a tab
   was revisited — a lateral, high-frequency action this project's
   own governing rule (section 19) says must stay instant. Scoping
   to a class that motion-enhance.js adds ONLY to freshly-inserted
   nodes, and removes once that card's own transition ends, fixes
   it: a persisting card no longer matches once marked "settled",
   so a later tab switch does nothing to it.

   motion-enhance.js also sets --entrance-delay per card, capped, so
   a big grid settles together in well under a second rather than
   cascading item-by-item for several seconds.

   Each of these classes already owns a `transition` shorthand — this
   file's own section 6 shadows style.css's for .library-poster-card
   with transform+box-shadow, while .movie-poster-card keeps style.css's
   transform+filter (no override exists for it here), and
   search-result-card/episode-card keep style.css's transform+border-color.
   `.tv-entrance` is written as a compound selector with each one,
   rather than a bare `.tv-entrance` rule, specifically so its own
   transition list is the FULL union rather than a same-specificity,
   later-in-source rule that would silently replace the existing list
   outright and drop the other property for as long as the card is
   entering (measured: a hover mid-entrance would have snapped instead
   of easing, since `transition` is a shorthand and the cascade doesn't
   merge two rules' lists — whichever one wins, wins wholesale).
   --------------------------------------------------------- */

.library-poster-card.tv-entrance {
  transition: opacity var(--dur-surface) var(--ease-surface),
              transform var(--dur-surface) var(--ease-surface),
              box-shadow var(--dur-2) var(--ease-out);
  transition-delay: var(--entrance-delay, 0ms);
}

.movie-poster-card.tv-entrance {
  transition: opacity var(--dur-surface) var(--ease-surface),
              transform var(--dur-surface) var(--ease-surface),
              filter var(--dur-hover) var(--ease-hover);
  transition-delay: var(--entrance-delay, 0ms);
}

.search-result-card.tv-entrance,
.episode-card.tv-entrance {
  transition: opacity var(--dur-surface) var(--ease-surface),
              transform var(--dur-surface) var(--ease-surface),
              border-color var(--dur-hover) var(--ease-hover);
  transition-delay: var(--entrance-delay, 0ms);
}

.discovery-card.tv-entrance {
  transition: opacity var(--dur-surface) var(--ease-surface),
              transform var(--dur-surface) var(--ease-surface);
  transition-delay: var(--entrance-delay, 0ms);
}

@media (prefers-reduced-motion: no-preference) {
  @starting-style {
    .tv-entrance {
      opacity: 0;
      transform: translateY(6px) scale(0.97);
    }
  }
}

/* Gentler, not gone — same pattern as section 14 #5: the distance
   and scale come out, a short fade stays, and the per-item cascade
   collapses to one simultaneous fade. */
@media (prefers-reduced-motion: reduce) {
  .tv-entrance {
    transition: opacity 120ms linear !important;
    transition-delay: 0ms !important;
    transform: none !important;
  }
}

/* ---------------------------------------------------------
   25. GLASS — THEME-CHANGE TRANSITION
   Section 2 already transitions body's background/color on a
   theme swap (--dur-theme). The glass chrome's tint (section 16)
   changes value on the same swap but had no transition of its
   own, so it cut instantly while everything behind it faded.
   --------------------------------------------------------- */

.top-header,
.bottom-navigation {
  transition: background-color var(--dur-theme) var(--ease-theme);
}

/* ---------------------------------------------------------
   26. DESKTOP NAV RAIL — ACTIVE INDICATOR
   The rail (section 22) signalled the active tab with a tint
   change alone. A persistent accent bar — common on desktop rail
   navigation — gives it a second, spatial cue, transform/opacity
   only so it costs nothing to run alongside the tint transition.
   --------------------------------------------------------- */

@media (min-width: 1040px) {
  .nav-button {
    position: relative;
  }

  .nav-button::before {
    content: "";
    position: absolute;
    left: 0;
    top: 50%;
    width: 3px;
    height: 20px;
    margin-top: -10px;
    border-radius: var(--r-sm);
    background: var(--accent);
    opacity: 0;
    transform: scaleY(0.4);
    transform-origin: center;
    transition: opacity var(--dur-state) var(--ease-state),
                transform var(--dur-state) var(--ease-state);
  }

  .nav-button.active-nav::before {
    opacity: 1;
    transform: scaleY(1);
  }
}

@media (min-width: 1040px) and (prefers-reduced-motion: reduce) {
  .nav-button::before {
    transition: opacity 120ms linear !important;
    transform: scaleY(1) !important;
  }
}

/* ---------------------------------------------------------
   27. DESKTOP BUG FIXES
   Everything here is inside the >=1040px query — mobile is
   deliberately untouched. Found by driving the real app at
   1440x900 rather than by inspection.
   --------------------------------------------------------- */

@media (min-width: 1040px) {
  /* 1. The rail's first item was clipped by the top edge: the rail
        started at y=0 with no leading padding. */
  .bottom-navigation {
    padding-top: var(--s-6);
    padding-bottom: var(--s-6);
    justify-content: flex-start;
    gap: var(--s-1);
  }

  /* The active indicator was a narrow strip that cropped the icon. */
  .bottom-navigation .nav-button {
    padding: var(--s-3) var(--s-2);
    border-radius: var(--r-md);
    min-height: 0;
  }

  /* 2. The header read as a floating card rather than page chrome.
        Let it sit flush with the column and carry the scroll edge. */
  /*
   * Sit flush with the content column instead of reading as a floating
   * card — but KEEP the glass. An earlier version of this rule also
   * cleared background/backdrop-filter, which stripped the material from
   * section 16 above 1040px and left the title illegible over a poster
   * grid scrolling underneath it.
   */
  .top-header {
    max-width: none;
    margin: 0;
    padding-left: var(--s-5);
    padding-right: var(--s-5);
    margin-left: calc(var(--s-5) * -1);
    margin-right: calc(var(--s-5) * -1);
  }

  /* 3. Form controls stretched the full 1060px column. A search field
        a metre wide is harder to use, not easier. */
  .library-search-box,
  .search-box,
  .movie-search-box,
  .library-tools label,
  .movie-library-tools label,
  .browse-genre-control {
    max-width: 520px;
  }

  .search-type-toggle,
  .library-type-toggle {
    max-width: 420px;
  }

  /* 4. Home cards were full-width rows holding a 60px poster and a lot
        of nothing. Two up reads as designed instead of stretched. */
  #continue-watching-list,
  #upcoming-list {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
    gap: var(--s-4);
    align-items: start;
  }

  /* 5. A swipe hint on a device with no touch. */
  .home-swipe-hint {
    display: none;
  }

  /* 6. Horizontal rails clipped mid-item with no scroll affordance —
        the same fade the chip rails already use. */
  .details-cast-section,
  .watch-providers-content {
    position: relative;
  }

  .details-cast-section::after,
  .watch-providers-content::after {
    content: "";
    position: absolute;
    inset: 0 0 0 auto;
    width: 48px;
    pointer-events: none;
    background: linear-gradient(to right, transparent, var(--background));
  }

  /* 7. Library posters were ~160px in a 1060px column. Give them room. */
  #shows-list,
  .movie-poster-grid,
  .discovery-grid {
    grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
  }
}

@media (min-width: 1040px) {
  /*
   * The rail's active indicator was narrower than the icon it sat
   * behind, so the icon overhung its own highlight. Fill the rail.
   */
  .bottom-navigation .nav-button {
    width: 100%;
    box-sizing: border-box;
  }

  /*
   * The segmented control's active pill overflowed the rounded
   * container: the buttons were not equal-width flex children, so the
   * selected one sized to its own text and spilled past the track.
   */
  .search-type-toggle,
  .library-type-toggle {
    display: flex;
  }

  .search-type-toggle > button,
  .library-type-toggle > button {
    flex: 1 1 0;
    min-width: 0;
  }
}

@media (min-width: 1040px) {
  /*
   * "Upcoming" was reachable only by a horizontal swipe, and the swipe
   * hint is hidden on desktop — so with zero pixels of peek the panel was
   * undiscoverable with a mouse. Stack the two panels instead of relying
   * on a touch gesture that isn't available.
   */
  .home-views-scroll {
    display: grid;
    grid-template-columns: 1fr;
    gap: var(--s-8);
    overflow-x: visible;
  }

  .home-view-panel {
    width: auto;
    min-width: 0;
    flex: none;
    scroll-snap-align: none;
  }
}

/* ---------------------------------------------------------
   28. SCALE NORMALISATION
   Measured across every screen: 10 off-scale font sizes against
   6 on-scale ones, and 7 off-scale radii. Nothing was wrong
   individually — but 12px sat beside 13px, 16px radius beside
   18px, and the eye reads that as sloppiness rather than as a
   system. Everything below snaps to the nearest existing step.
   --------------------------------------------------------- */

/* --- TYPE: snap to the --step-* scale --- */

/* 30.1px / 24px headings -> --step-3 (28px) */
.page-title h2,
#library-heading,
.home-view-panel .app-h2,
.home-panel-heading h2 {
  font-size: var(--step-3);
}

/* Poster placeholder initials: 25.8 / 26 -> --step-2 (22px) */
.library-poster-placeholder,
.details-poster-placeholder {
  font-size: var(--step-2);
}

/* Stat numerals were 20px AND 25px in the same row -> one value */
.stat-number {
  font-size: var(--step-2);
}

/* 18px strays -> --step-1 (17px) */
.details-lists-section h3,
.watch-providers-section h3,
.seasons-section h3 {
  font-size: var(--step-1);
}

/* 14px -> --step--1 (13px) */
.home-series-title {
  font-size: var(--step-0);
}

/* The 12px cluster (9 elements) -> --step--1 (13px). It sat one pixel
   off the app's own small-text size for no reason. */
.home-episode-name,
.home-episodes-left,
.details-lists-label,
.details-status,
.watch-providers-empty,
.details-lists-section p,
.details-lists-section button {
  font-size: var(--step--1);
}

/* 10px / 10.1px -> --step--2 (11px), the smallest sanctioned size */
.discovery-rating,
.discovery-rating span {
  font-size: var(--step--2);
}

/* --- SHAPE: snap to the --r-* scale --- */

/* 22px -> --r-lg (18px), matching every other large card */
.details-header,
.saved-details-header {
  border-radius: var(--r-lg);
}

/* Progress bars were 20px on a 3px-tall element — a pill, not a corner */
.progress-bar,
.progress-fill,
.library-poster-progress,
.library-poster-progress-fill {
  border-radius: var(--r-full);
}

/* 16px -> --r-lg (18px) */
.discovery-poster-frame,
.library-poster-frame,
.details-poster,
.details-action-popover {
  border-radius: var(--r-lg);
}

/* 14px / 13px / 11px -> --r-md (12px) */
.details-lists-section,
.status-select,
.details-status-select,
.movie-danger-button,
.home-episode-poster-placeholder,
select,
.season-button,
.filter-button {
  border-radius: var(--r-md);
}

/* Round things use the same token as every other pill */
.profile-circle,
.settings-avatar,
.episode-check-button {
  border-radius: var(--r-full);
}

/* Higher-specificity stragglers that survived the first normalisation
   pass — style.css scopes several of these under a parent, so matching
   selector-for-selector is what makes them yield. */

.statistics-row .stat-number,
.statistics-grid .stat-card-episodes .stat-number,
.statistics-grid .stat-card:not(.stat-card-full) .stat-number {
  font-size: var(--step-2);
}

.details-poster-placeholder,
.movie-details-poster,
.details-poster {
  border-radius: var(--r-lg);
}

.details-action-menu > summary,
.details-action-menu[open] > summary {
  border-radius: var(--r-md);
}

select,
.library-search-box input,
.movie-library-tools select,
.library-tools select {
  border-radius: var(--r-md);
}

/*
 * The drift originated in style.css's bare element styles: h2 at 24px and
 * h3 at 18px, neither on the scale. Setting the base here fixes a long
 * tail of headings at once, rather than chasing each component.
 *
 * Two h2 sizes are deliberate and both on-scale: a page title (--step-3)
 * and a section heading (--step-2).
 */
h2 {
  font-size: var(--step-2);
}

h3 {
  font-size: var(--step-1);
}

h4 {
  font-size: var(--step-0);
}

/* Nothing should render below the smallest sanctioned size. A few spans
   inherited em-based sizes and landed at 10.1px. */
.discovery-rating,
.discovery-rating *,
.home-episode-code,
.stat-label,
.eyebrow {
  font-size: var(--step--2);
}

/*
 * Final stragglers, each traced to a specific rule in style.css rather
 * than guessed. Note: inputs and selects sit at 16px deliberately — below
 * that iOS zooms the whole page on focus — so 16px is a sanctioned size
 * for form controls even though it is not a --step-* value.
 */

/* A 12px cluster one pixel under the app's own small-text size. */
.details-lists-chip,
.details-lists-section strong,
.movie-cast-card span,
.movie-cast-card strong,
.watch-providers-list span,
.home-episode-card span:not(.home-episodes-left),
.search-result-card span,
.discovery-card span,
.stats-list-count {
  font-size: var(--step--1);
}

/* #time-stat carried its own 24px inside a media query. */
#time-stat,
.stat-card .stat-number,
.statistics-row .stat-card .stat-number {
  font-size: var(--step-2);
}

/* Details-page heading matched the page-title size; it is a section. */
#show-details-content h2,
#movie-details-content h2 {
  font-size: var(--step-2);
}

/* Two button radii at 14px and 11px against a 12px scale step. */
.details-action-menu summary,
.details-lists-section button,
.episode-rewatch-undo,
.library-undo-button,
.library-delete-button,
.season-watch-button,
.season-watch-button-complete {
  border-radius: var(--r-md);
}

/* Last measured stragglers, each traced to its element. */
.search-box button,
.movie-search-box button {
  border-radius: var(--r-md);
}

.browse-genre-control > span,
.library-results-header p,
.details-action-popover strong,
.details-action-popover label {
  font-size: var(--step--1);
}

.stat-card.watch-time-card .stat-number,
#movie-time-stat {
  font-size: var(--step-2);
}

.home-swipe-complete span {
  font-size: var(--step-1);
}

/* The two final off-scale values, each traced to its own rule. */
.statistics-row .watch-time-card .stat-number,
.statistics-row .stat-card .stat-number,
.stat-card .stat-number {
  font-size: var(--step-2) !important;
}

/* 17px against an 18px step — the swipe-reveal row behind a card. */
.home-swipe-row {
  border-radius: var(--r-lg);
}

/* ---------------------------------------------------------
   29. LOADING
   Waiting was 14 different plain strings ("Loading shows...").
   Static text during a wait reads as a stall; motion reads as
   progress. Two treatments:
     - a bouncing-dot line for text waits
     - skeleton cards where a grid of posters is about to arrive
   --------------------------------------------------------- */

.tv-loading {
  display: inline-flex;
  align-items: baseline;
  gap: 1px;
  color: var(--muted);
  font-size: var(--step--1);
}

.tv-loading-label {
  /* A slow sweep across the label so the whole line feels alive, not
     just the dots. */
  background: linear-gradient(
    90deg,
    var(--muted) 0%,
    var(--text) 45%,
    var(--muted) 90%
  );
  background-size: 220% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  animation: loading-sweep 1.9s var(--ease) infinite;
}

.tv-loading-dot {
  display: inline-block;
  animation: loading-bounce 1.25s var(--ease) infinite;
  animation-delay: calc(var(--dot-index, 0) * 140ms);
}

@keyframes loading-sweep {
  from { background-position: 120% 0; }
  to   { background-position: -60% 0; }
}

@keyframes loading-bounce {
  0%, 62%, 100% { transform: translateY(0); opacity: 0.45; }
  28%           { transform: translateY(-0.32em); opacity: 1; }
}

/* Skeleton grid: the shape of what is coming, so the layout does not
   jump when it arrives. */
.tv-skeleton-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
  gap: var(--s-3);
  width: 100%;
}

@media (min-width: 1040px) {
  .tv-skeleton-grid {
    grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
  }
}

.tv-skeleton-grid .skeleton {
  animation-delay: calc(var(--cell-index, 0) * 90ms);
}

/*
 * Reduced motion: keep a gentle pulse so a wait still reads as "working",
 * but drop the travel and the sweep.
 */
@media (prefers-reduced-motion: reduce) {
  .tv-loading-label {
    animation: none;
    background: none;
    -webkit-background-clip: initial;
    background-clip: initial;
    color: var(--muted);
  }

  .tv-loading-dot {
    animation: loading-fade 1.4s ease-in-out infinite;
  }

  @keyframes loading-fade {
    0%, 100% { opacity: 0.4; }
    50%      { opacity: 1; }
  }
}

/* ---------------------------------------------------------
   30. PRESS — 3D touch
   The old press was scale(0.975) over 200ms: a 2.5% change that
   is invisible, arriving too slowly to read as a response.

   A press has two phases. The FIRST must be effectively instant
   or directness collapses; the second continues while the finger
   stays down, which is what makes it feel like a physical object
   being pushed rather than a state toggle.

   Done as a keyframe rather than a transition so both phases fit
   in one declaration: ~110ms to the initial depth, then a slow
   deepen for as long as it is held. Release drops the animation
   and the base transition carries it home.

   transform only — no layout, no paint, no lag.
   --------------------------------------------------------- */

.library-poster-card,
.movie-poster-card,
.home-episode-card,
.discovery-card,
.search-result-card,
.stat-card,
.stats-list-card {
  transition: transform var(--dur-state) var(--ease-out),
              box-shadow var(--dur-state) var(--ease-out);
  transform-origin: center;
}

/*
 * will-change is deliberately NOT set here. Setting it on a card class
 * promotes EVERY card to its own compositor layer permanently — measured
 * at 251 layers for a 123-show library. A desktop GPU absorbs that; a
 * phone does not, and it is a classic cause of exactly the "smooth on my
 * laptop, janky on my phone" gap. Promote only for the moment of the
 * interaction instead.
 */
.library-poster-card:active,
.movie-poster-card:active,
.home-episode-card:active,
.discovery-card:active,
.search-result-card:active,
.stat-card:active,
.stats-list-card:active {
  will-change: transform;
}

.library-poster-card:active,
.movie-poster-card:active,
.home-episode-card:active,
.discovery-card:active,
.search-result-card:active,
.stat-card:active,
.stats-list-card:active {
  animation: press-3d 700ms var(--ease-out) forwards;
}

@keyframes press-3d {
  0% {
    transform: perspective(760px) scale(1) translateZ(0);
  }
  16% {
    /* ~110ms in: the response the finger is waiting for. */
    transform: perspective(760px) scale(0.955) translateZ(-8px);
  }
  100% {
    /* Keeps deepening while held. */
    transform: perspective(760px) scale(0.938) translateZ(-16px);
  }
}

/* Smaller controls press less — a chip sinking 16px reads as broken. */
.nav-button:active,
.filter-button:active,
.discovery-filter:active,
.season-button:active,
.season-strip-cell:active,
.theme-option:active,
.movie-secondary-button:active,
.primary-button:active,
.back-button:active,
.empty-action:active {
  animation: press-3d-small 700ms var(--ease-out) forwards;
}

@keyframes press-3d-small {
  0%   { transform: perspective(600px) scale(1) translateZ(0); }
  16%  { transform: perspective(600px) scale(0.93) translateZ(-4px); }
  100% { transform: perspective(600px) scale(0.915) translateZ(-8px); }
}

@media (prefers-reduced-motion: reduce) {
  .library-poster-card:active,
  .movie-poster-card:active,
  .home-episode-card:active,
  .discovery-card:active,
  .search-result-card:active,
  .stat-card:active,
  .stats-list-card:active,
  .nav-button:active,
  .filter-button:active,
  .season-strip-cell:active,
  .primary-button:active,
  .empty-action:active {
    animation: none;
    opacity: 0.72;
  }
}

/* ---------------------------------------------------------
   31. PAGE ENTRANCE — one layer, not N
   Measured under 4x CPU throttle (a mid-range phone): switching
   to the Library forces 13.3ms of synchronous layout, because
   .page uses display:none and showing it lays the whole subtree
   out from scratch. The 60fps budget is 16.7ms.

   The previous version then animated all 8 direct children
   independently, on top of that layout. Eight simultaneous
   animations, each a compositor candidate, starting in the
   ~3ms of frame budget that layout left. That is the stutter.

   Now ONE element animates: the page container itself. Same
   perceived effect, one layer instead of eight, and no
   per-child animation competing with the reflow.
   --------------------------------------------------------- */

.page.active-page {
  animation: content-rise var(--dur-state) var(--ease-out) both;
}

@keyframes content-rise {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: translateY(0); }
}

/*
 * Hardware hint, scoped to the moment it is needed. A standing
 * will-change on page containers would keep a layer allocated for every
 * tab permanently — the same layer-explosion mistake that made the card
 * grid janky on phones.
 */
.page.active-page.is-entering {
  will-change: transform, opacity;
}

/*
 * The single biggest win available for display:none -> block.
 * content-visibility lets the browser cache the subtree's rendered
 * state, so re-showing a tab skips re-laying-out its whole tree.
 * Chrome/Edge and Safari 18+; everything else simply keeps the old
 * behaviour, so this is pure progressive enhancement.
 */
@supports (content-visibility: auto) {
  .page {
    content-visibility: auto;
    /* Prevents a scrollbar jump while a hidden tab is un-rendered. */
    contain-intrinsic-size: auto 600px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .page.active-page {
    animation: content-fade 180ms linear both;
  }

  @keyframes content-fade {
    from { opacity: 0; }
    to   { opacity: 1; }
  }
}

/* ---------------------------------------------------------
   32. PAGE TRANSITION FALLBACK
   The View Transitions API is not in iOS Safari before 18, and
   without it a hierarchical push had no page-level animation at
   all — measured as `animationName: none`. That is why
   navigation read as abrupt on phones but smooth on desktop.

   These classes are applied by the JS fallback path and produce
   the same push/pop language as the native transition.
   --------------------------------------------------------- */

.page.is-nav-push {
  animation: nav-push-in var(--dur-page) var(--ease-page) both;
}

.page.is-nav-pop {
  animation: nav-pop-in var(--dur-page) var(--ease-page) both;
}

@keyframes nav-push-in {
  from { opacity: 0; transform: translateX(9%); }
  to   { opacity: 1; transform: translateX(0); }
}

@keyframes nav-pop-in {
  from { opacity: 0; transform: translateX(-5%); }
  to   { opacity: 1; transform: translateX(0); }
}

/*
 * While the page itself is travelling, its children must NOT also stagger
 * in — two animations on nested elements read as a stutter, not one move.
 */
.page.is-nav-push > *,
.page.is-nav-pop > * {
  animation: none;
}

@media (prefers-reduced-motion: reduce) {
  .page.is-nav-push,
  .page.is-nav-pop {
    animation: content-fade 200ms linear both;
  }
}

/*
 * Mobile gets a slightly longer beat than desktop. A phone is held
 * closer, the travel is a larger share of the screen, and there is no
 * pointer precision to reward snappiness — the same duration that feels
 * crisp on a laptop reads as clipped in the hand.
 */
@media (max-width: 700px) {
  :root {
    --dur-page: 520ms;
    --dur-surface: 380ms;
  }
}

/* ---------------------------------------------------------
   33. HOME SWIPE REVEAL — no longer exposed by a press
   style.css paints a hardcoded green gradient (#18864b -> #28a964)
   behind every Up Next card, as the swipe-to-mark-watched reveal.

   That was invisible while the press was scale(0.975). The new 3D
   press goes to 0.938, so the green now flashed behind the card on
   EVERY press — an accidental, off-palette colour appearing on an
   action that has nothing to do with marking anything watched.

   The reveal now only exists during an actual swipe, and uses the
   theme's own positive colour rather than a raw green.
   --------------------------------------------------------- */

.home-swipe-row {
  background: transparent;
}

.home-swipe-complete {
  opacity: 0;
  transition: opacity var(--dur-press) var(--ease-press);
}

/* Only while the card is genuinely being dragged. */
.home-episode-card.is-swiping ~ .home-swipe-complete,
.home-swipe-row:has(.home-episode-card.is-swiping) {
  background: linear-gradient(
    90deg,
    color-mix(in srgb, var(--positive) 88%, black),
    var(--positive)
  );
}

.home-swipe-row:has(.home-episode-card.is-swiping) .home-swipe-complete {
  opacity: 1;
}
