/* ============================================================================
   app.css — styles for THIS app's pages only. The design system lives in
   css/kit/ (tokens → primitives → chrome); everything here is composition on
   top of those tokens. No raw colours, no raw spacing: if a value isn't a
   token, it's a layout number (a column width, an aspect ratio).

   Responsive strategy: the poster grid is intrinsically fluid (auto-fill +
   minmax), so it reflows without breakpoints. Only the two genuinely
   two-dimensional layouts — the detail hero and the top strip — need media
   queries, and they collapse to a single column at 720px.
   ============================================================================ */

/* --- tap feedback --------------------------------------------------------- */

/* Kill the translucent block mobile browsers paint over anything tappable. It's
   drawn on the border box, so on a poster tile it flashes a hard-edged rectangle
   across rounded artwork, and on the star row it lights up a slice that has no
   visible edges of its own. It also fires on a touch that turns out to be the
   start of a scroll.

   Inherited, so `:root` covers the document. This is NOT the focus ring — the
   `:focus-visible` outlines everywhere are untouched, and a keyboard user loses
   nothing. What it does remove is the only press feedback some of these controls
   had, which is why everything below earns its own `:active`. */
:root {
  -webkit-tap-highlight-color: transparent;
}

/* The kit already presses `.btn` 1px into the page (primitives.css). This app's
   own tappable things had nothing but the highlight, so they each grow an
   `:active` — see `.tile`, `.rating__stars` and the two text buttons below. */
.rating__clear:active,
.landing__toggle:active { opacity: 0.55; }

/* --- page container ------------------------------------------------------ */

/* The kit's .container is 780px — right for prose, too narrow for a poster
   wall. This widens it without touching the kit. */
.container--wide {
  max-width: 1100px;
}

/* .page-header is a KIT class (flex, space-between, centred). Only the deltas
   belong here: a heading with a count under it wants top alignment, and it has
   to wrap on a phone. */
.page-header {
  align-items: flex-end;
  flex-wrap: wrap;
  margin-bottom: var(--space-5);
}
.page-header h1 { margin: 0; }

/* The media filter uses the kit's .tabs/.tab wholesale; these are the two deltas
   from the kit's <button> tabs. The strip drives the server, so each tab is a
   link — and a link needs the underline off and the button's baseline back. */
.tabs a.tab { text-decoration: none; line-height: normal; }
.tab__count { color: var(--text-muted); font-size: var(--text-sm); }
.tab[aria-selected="true"] .tab__count { color: inherit; opacity: 0.7; }

/* Same gap the grid keeps from the header, since the strip now sits between. */
.tabs + .tiles, .tabs + .empty { margin-top: var(--space-5); }

/* --- top bar layout ------------------------------------------------------- */

/* Three slots. Both outer ones flex equally, so the search field settles on the
   true centre of the bar no matter how wide the brand or the account get —
   which `margin: 0 auto` on the field alone can't do. */
.topbar__side {
  flex: 1;
  display: flex;
  align-items: center;
  min-width: 0;
}
.topbar__side--end { justify-content: flex-end; gap: var(--space-5); }

/* The kit sizes .topbar__logo with font-size, which suited the ◆ character it
   shipped with; an SVG needs real dimensions. */
.topbar__logo { display: inline-flex; align-items: center; }
.topbar__logo svg { width: 1.3rem; height: 1.3rem; }
/* Kit default is flex:1 — that fights the three-slot layout, and the menu is
   inside the end slot now, which already does the flexing. */
.topbar__menu { flex: none; }

/* The shelf count beside a nav link. Not the kit's `.badge`: that's a pill sized
   to stand alone in a card, and three of them across the bar would weigh as much
   as the links they annotate. This is the number and nothing else — a size down,
   tabular so the three sit at consistent widths, and dimmer than its label, since
   it's a footnote to the link and not a second thing to read.

   The link is inline flow, so the count needs no layout of its own beyond the
   space before it. */
.topbar__count {
  margin-left: 0.4em;
  font-size: var(--text-xs);
  font-variant-numeric: tabular-nums;
  opacity: 0.65;
}
/* An empty span is how a zero renders — see `shelf_badge` — and this is what makes
   it cost nothing. The element still has to exist for the out-of-band swap to find
   it, so it can't simply not be rendered. */
.topbar__count:empty { display: none; }
/* On the active link the count sits on the accent wash, where 0.65 opacity drops
   it below the label it belongs to. Full strength there, and the size difference
   still keeps them apart. */
.topbar__link--active .topbar__count { opacity: 1; }

/* --- nav search ----------------------------------------------------------- */

.navsearch {
  position: relative;
  flex: 0 1 26rem;
  display: flex;
  align-items: center;
}
.navsearch__icon {
  position: absolute;
  left: var(--space-3);
  display: grid;
  place-items: center;
  color: var(--text-muted);
  pointer-events: none;
}
.navsearch__icon svg { width: 1rem; height: 1rem; }
/* Rides on the kit's .input (fill, focus ring, placeholder). These are the only
   differences: a pill in the bar rather than a rounded rect, room for the
   magnifier, and --bg so it recesses against the nav's surface. */
.navsearch__input {
  padding-left: var(--space-7);
  font-size: var(--text-sm);
  background: var(--bg);
  border-radius: var(--radius-full);
}
/* The native clear affordance duplicates Escape and lands on the icon's side. */
.navsearch__input::-webkit-search-cancel-button { display: none; }

/* Rides on the kit's .dropdown__menu. Differences only: it spans the field
   rather than sitting at a min-width, and a long result list scrolls. */
.navsearch__panel {
  right: 0;
  min-width: 0;
  max-height: 70vh;
  overflow-y: auto;
}
/* Empty panel = no chrome. Without this an untouched field shows a bare floating
   card the moment it takes focus. */
.navsearch__panel:empty { display: none; }

.navsearch__list { list-style: none; margin: 0; padding: 0; }
/* Rides on the kit's .dropdown__item, which is display:block — these rows put a
   thumbnail beside the text, so they need to be flex. */
.navsearch__item {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-2);
}
/* Keyboard highlight reuses the kit's hover tint — there's only ever one "the row
   you're about to open", whichever input put you there. */
.navsearch__item.is-active { background: var(--hover-tint); }
.navsearch__thumb {
  width: 2.25rem;
  height: 3.375rem;         /* 2:3, same as the poster tiles */
  object-fit: cover;
  border-radius: var(--radius-1);
  background: var(--field);
  flex: none;
}
.navsearch__meta { min-width: 0; }
.navsearch__title {
  font-size: var(--text-sm);
  font-weight: var(--weight-medium);
  /* Long titles truncate rather than wrapping the row to two lines. */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.navsearch__sub { font-size: var(--text-xs); }
.navsearch__empty { padding: var(--space-4); font-size: var(--text-sm); text-align: center; }
/* --- recent searches ------------------------------------------------------- */

/* A header row, not a footer like "See all results": clearing the list is
   housekeeping, and housekeeping goes where you can see what it applies to
   without scrolling past eight rows to find it. */
.navsearch__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-4);
  padding: var(--space-2) var(--space-2) var(--space-1);
  font-size: var(--text-xs);
}
.navsearch__clear {
  border: 0;
  padding: 0;
  font: inherit;
  color: var(--accent-text);
  font-weight: var(--weight-medium);
  background: none;
  cursor: pointer;
}
.navsearch__clear:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--accent-soft); border-radius: var(--radius-1); }
/* The row is the anchor plus its delete button. The anchor takes the slack so the
   whole width of the term is clickable, and only the × sits outside it. */
.navsearch__row { display: flex; align-items: center; }
/* Same row treatment as a suggestion — hover tint, keyboard highlight — minus the
   thumbnail, which is what makes these read as a lighter class of row without
   inventing a second style for them. */
.navsearch__term {
  flex: 1;
  min-width: 0;
  font-size: var(--text-sm);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Muted until you're on the row: eight × glyphs down a panel you opened to pick
   something from is a column of exits competing with the terms themselves. Full
   strength on hover and on focus, so it's there the moment you go for it. */
.navsearch__drop {
  flex: none;
  display: grid;
  place-items: center;
  width: 2rem;
  height: 2rem;
  border: 0;
  padding: 0;
  border-radius: var(--radius-1);
  color: var(--text-muted);
  background: none;
  opacity: 0.45;
  cursor: pointer;
  transition: opacity var(--transition), background var(--transition);
}
.navsearch__drop svg { width: 0.85rem; height: 0.85rem; }
.navsearch__row:hover .navsearch__drop { opacity: 1; }
.navsearch__drop:hover { background: var(--hover-tint); }
.navsearch__drop:focus-visible {
  opacity: 1;
  outline: none;
  box-shadow: 0 0 0 3px var(--accent-soft);
}

/* Also a .dropdown__item; the rule and centring set it apart as the footer. */
.navsearch__all {
  margin-top: var(--space-1);
  border-top: 1px solid var(--rule);
  color: var(--accent-text);
  font-size: var(--text-sm);
  font-weight: var(--weight-medium);
  text-align: center;
}

/* --- account menu (inside the top bar) ------------------------------------ */

/* Sits in .topbar__controls, so the kit's chrome.css already collapses it behind
   the hamburger at 720px along with the links. */
.account { position: relative; }

.account__trigger {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-1) var(--space-2);
  border: none;
  border-radius: var(--radius-full);
  background: transparent;
  color: var(--text);
  font: inherit;
  font-size: var(--text-sm);
  cursor: pointer;
  transition: background var(--transition);
}
.account__trigger:hover { background: var(--hover-tint); }
.account__pic {
  width: 1.5rem;
  height: 1.5rem;
  border-radius: var(--radius-full);
  object-fit: cover;
}
.account__name { font-weight: var(--weight-medium); }

/* Right-anchored: the trigger is at the end of the bar, so a left-anchored panel
   would hang off the edge. */
.account__menu { right: 0; left: auto; }

/* Kit gap: .dropdown__item styles a <button> and an <a> identically but never
   resets text-decoration, so the link variant comes out underlined while the
   button next to it doesn't. Patched here rather than in kit/primitives.css so
   the vendored files stay byte-identical to upstream — worth pushing back to
   Lumen, since its own dropdown_menu() has the same problem. */
a.dropdown__item { text-decoration: none; }

/* The account is a dropdown on desktop and flat links inside the burger panel on
   mobile. Those are two different things, so they're two bits of markup swapped
   by a media query — rather than one dropdown contorted to be both, which means
   a nested dropdown and CSS fighting Alpine's inline styles for control of
   `display`. Each half is plain and neither knows about the other. */
.topbar__account { display: none; }

@media (max-width: 720px) {
  .topbar__controls { display: none; }        /* the avatar dropdown */
  .topbar__account { display: block; }        /* the flat links */

  /* Search stops being a centred field and just takes the room left over — it's
     the main action on a phone, so it stays in the bar rather than collapsing
     into the burger with the links. */
  .topbar__side { flex: none; }
  .navsearch { flex: 1; min-width: 0; }
  /* The mark alone; the mark plus the wordmark plus a search field won't fit.
     font-size:0 collapses the brand's text node — the SVG has its own size and
     is unaffected. */
  .topbar__brand { font-size: 0; gap: 0; }
}

.topbar__account {
  border-top: 1px solid var(--rule);
  margin-top: var(--space-2);
  padding-top: var(--space-2);
}
.topbar__account-who {
  padding: var(--space-1) var(--space-3);
  font-size: var(--text-xs);
}
/* Sign out has to be a form button (a POST, not a link) but must sit flush with
   the links around it. */
.topbar__account form { margin: 0; }
button.topbar__link {
  width: 100%;
  border: none;
  background: none;
  font: inherit;
  font-size: var(--text-sm);
  font-weight: var(--weight-medium);
  text-align: left;
  cursor: pointer;
}

/* --- poster grid ---------------------------------------------------------- */

/* auto-fill (not auto-fit) so a lone result stays poster-sized instead of
   stretching across the whole row. */
.tiles {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
  gap: var(--space-4);
}

.tile {
  display: block;
  color: inherit;
  text-decoration: none;
  transition: transform var(--transition);
}
.tile:hover { transform: translateY(-2px); }
/* After :hover deliberately — same specificity, so the later rule wins, and a
   touch that leaves :hover stuck behind it would otherwise swallow the press. */
.tile:active { transform: scale(0.985); }

.tile__art {
  position: relative;
  aspect-ratio: 2 / 3;          /* the universal poster ratio */
  border-radius: var(--radius-3);
  overflow: hidden;
  background: var(--field);
  box-shadow: var(--shadow-1);
}
.tile__img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.tile__blank {
  width: 100%;
  height: 100%;
  display: grid;
  place-items: center;
  font-size: var(--text-2xl);
  font-weight: var(--weight-bold);
  color: var(--text-muted);
}
/* The upper-left corner, for which shelf a search result is already on. Top-left
   is where a left-to-right reader starts, and that flag is the one thing that can
   stop you clicking — it has to land before the title does. */
.tile__badge {
  position: absolute;
  top: var(--space-2);
  left: var(--space-2);
  pointer-events: none;
}

/* Two slots pinned across the bottom of the artwork: your rating on the left,
   TMDB's on the right. */
.tile__flags {
  position: absolute;
  inset: auto var(--space-2) var(--space-2) var(--space-2);
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: var(--space-1);
  pointer-events: none;
}

/* The kit's badge tints are translucent (--accent-soft is 12% alpha) — fine on a
   flat surface, illegible over poster art, which is arbitrary colour and detail.
   Over artwork the badge has to carry its own opaque background. The tone still
   picks the hue; only the alpha changes. */
.tile__flags .badge,
.tile__badge .badge {
  background: var(--accent);
  color: var(--on-accent);
  box-shadow: var(--shadow-1);
  /* Tiles get small on a phone; two badges on one poster need the tighter box. */
  padding: 0.1rem var(--space-2);
  font-size: var(--text-xs);
  white-space: nowrap;
}
.tile__flags .badge--success { background: var(--success); color: #fff; }
.tile__flags .badge--warning { background: var(--warning); color: #fff; }
.tile__flags .badge--danger  { background: var(--danger);  color: #fff; }
/* Dark mode lightens those three hues (see tokens.css), so their ink has to flip
   with them or the badge loses its contrast. */
[data-theme="dark"] .tile__flags .badge--success,
[data-theme="dark"] .tile__flags .badge--warning,
[data-theme="dark"] .tile__flags .badge--danger { color: #000; }

/* The community score. Deliberately NOT accent — accent is reserved for the
   user's own rating on the opposite corner, and two accent badges on one poster
   would read as the same thing twice. Opaque surface, since it sits on artwork. */
.tile__flags .badge--score {
  background: var(--surface);
  color: var(--text);
}
/* em-relative, so the glyph tracks the badge's font-size instead of needing its
   own breakpoint when the tiles shrink. */
.tile__flags .badge--score svg {
  width: 0.95em;
  height: 0.95em;
  flex: none;
  opacity: 0.75;
}
/* Flush along the bottom edge of the artwork. Thin enough (3px) to clear the
   badges, which sit a --space-2 inset above it. */
.tile__progress {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 3px;
  background: color-mix(in srgb, #000 45%, transparent);
}
.tile__progress-fill {
  height: 100%;
  background: var(--accent);
}

.tile__title {
  margin-top: var(--space-2);
  font-size: var(--text-sm);
  font-weight: var(--weight-medium);
  line-height: 1.3;
}
.tile__note { font-size: var(--text-xs); }

.empty {
  display: grid;
  justify-items: center;
  gap: var(--space-4);
  padding: var(--space-8) var(--space-5);
  text-align: center;
}

/* --- page heading count -------------------------------------------------- */

.page-header__count { margin: var(--space-1) 0 0; font-size: var(--text-sm); }

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

.searchbar {
  display: flex;
  gap: var(--space-3);
  margin-bottom: var(--space-6);
}
.searchbar__input { flex: 1; }

/* --- detail hero ---------------------------------------------------------- */

/* Full-bleed backdrop art. It's a direct child of <body>, so absolute
   positioning resolves against the initial containing block — left/right:0 gives
   true full width with no 100vw (which overflows by the scrollbar's width). It
   starts at the top of the document so the sticky frosted nav blurs over it. */
.backdrop {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 30rem;
  background-size: cover;
  background-position: center 20%;
  opacity: 0.4;
  pointer-events: none;
}

/* Page content shares the body's stacking context with .backdrop, and a
   positioned element paints above unpositioned content — so the content has to
   be positioned too, or the art covers it. */
.container--wide { position: relative; z-index: 1; }

.hero {
  position: relative;
  z-index: 1;
  display: grid;
  grid-template-columns: 200px 1fr;
  gap: var(--space-6);
  align-items: start;
}
/* A column, not just a frame: the star rating hangs below the art. */
.hero__art { display: grid; gap: var(--space-4); align-content: start; }
.hero__art img,
.hero__art .tile__blank {
  width: 100%;
  aspect-ratio: 2 / 3;
  object-fit: cover;
  border-radius: var(--radius-3);
  box-shadow: var(--shadow-3);
  background: var(--field);
}
.hero__body { display: grid; gap: var(--space-4); }
.hero__body h1 { margin: 0; }
.hero__body p { margin: 0; }

/* Positioned so the toggle can sit on top of the faded tail on narrow screens.
   The wide layout never clamps, so the button never shows there and this is
   inert. */
.hero__synopsis { position: relative; display: grid; gap: var(--space-2); }
/* Centred in both states — over the faded tail while clamped, and in flow below
   the text once open. The alignment has to match or the toggle jumps sideways as
   you open it, which reads as the button moving rather than the text growing. */
.hero__synopsis .btn {
  justify-self: center;
  color: var(--accent);
  font-weight: var(--weight-medium);
}
.hero__chev { display: grid; place-items: center; flex: none; }
.hero__chev svg {
  display: block;
  width: 1em;
  height: 1em;
  transition: transform var(--transition);
}
.hero__synopsis.is-open .hero__chev svg { transform: rotate(180deg); }

/* This text sits on backdrop art, not on the flat page, so it can't use the
   muted grey — against a busy image it drops below readable. Both lines take the
   full --text colour and lean on weight and size for hierarchy instead, with a
   soft halo to hold the edges apart from whatever is behind them. */
.hero__meta,
.hero__overview {
  color: var(--text);
  text-shadow: 0 1px 2px var(--bg), 0 0 12px var(--bg);
}
.hero__meta { font-size: var(--text-sm); font-weight: var(--weight-medium); }
.hero__overview { max-width: 60ch; line-height: 1.6; }

/* --- shelf control -------------------------------------------------------- */

.shelf {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  flex-wrap: wrap;
}
.shelf__seg { display: flex; gap: var(--space-2); flex-wrap: wrap; }
.shelf__tick { display: grid; place-items: center; flex: none; }
.shelf__tick svg { display: block; width: 0.8em; height: 0.8em; }

/* .btn--sm sizes from padding + font-size and lands around 25px, which is under
   any usable touch target — this is the primary control on the page, so it gets
   an explicit floor instead. .btn is already inline-flex/centred, so dropping the
   vertical padding for a fixed height needs no re-centring. */
.shelf .btn {
  height: 2rem;
  padding-top: 0;
  padding-bottom: 0;
}

/* --- rating stars --------------------------------------------------------- */

/* Two identical rows of five stars stacked on top of each other — hollow
   underneath, solid above — with the solid one clipped to a width. That's what
   makes the fill a single percentage rather than five pieces of per-star state,
   and it lands on half stars for free if the input ever grows a drag gesture.

   The inner row of the clipped layer has to carry the full track width, or it
   would reflow to whatever the clip left it and the stars would slide as the
   score changed. */
/* Sized to the star row rather than to the column, so "Clear" centres under the
   stars and not under whatever width the grid cell happens to have. The block
   centres under the artwork on the wide layout; the narrow one, where the cell is
   the full page and the poster sits at its left edge, pulls it back to the
   start. */
.rating {
  position: relative;
  display: grid;
  gap: var(--space-2);
  justify-items: center;
  width: max-content;
  justify-self: center;
}
.rating__stars {
  --star: 2rem;
  --star-gap: 0.25rem;
  position: relative;
  width: calc(5 * var(--star) + 4 * var(--star-gap));
  height: var(--star);
  transition: transform var(--transition);
}
/* The hit slices are invisible, so the press has to land on the stars they cover.
   The row dips as one rather than only the slice you happened to hit — which
   would read as five separate controls instead of one scale. */
.rating__stars:active { transform: scale(0.97); }
.rating__row {
  position: absolute;
  inset: 0;
  display: flex;
  gap: var(--star-gap);
  width: calc(5 * var(--star) + 4 * var(--star-gap));
}
.rating__row svg { width: var(--star); height: var(--star); flex: none; }
.rating__track { color: var(--rule-strong); }
.rating__track svg { fill: none; }
.rating__fill {
  position: absolute;
  inset: 0;
  overflow: hidden;
  color: var(--accent);
  transition: width var(--transition);
}
.rating__fill svg { fill: currentColor; }

/* No caption underneath: a row of stars under a poster explains itself, and the
   label it would carry is on the group as `aria-label` for the readers that
   can't see it. Only the undo needs saying. */
/* In flow under the stars on the wide layout, where growing the artwork column by
   one short line moves nothing — the hero body is a separate cell. The narrow
   layout can't afford that; see the media query. */
.rating__clear {
  border: none;
  background: none;
  padding: 0;
  font: inherit;
  font-size: var(--text-xs);
  color: var(--text-muted);
  text-decoration: underline;
  text-underline-offset: 0.2em;
  cursor: pointer;
  transition: color var(--transition);
}
.rating__clear:hover { color: var(--text); }

/* The confirmation that a tap landed. The whole control is replaced on every
   rating post, so a CSS transition has nothing to transition *from* — the
   swapped-in node just starts at its final state. Hence a keyframe animation,
   gated on a class the server only sets when the score was actually set, so it
   stays quiet on page load and on the out-of-band re-renders that a shelf change
   or an episode tick drag along with them.

   It scales down rather than up: the filled layer is clipped to the score, and a
   star growing past that clip would have its outer point shaved off — most
   visibly at five stars, where the clip is the container's own edge. Every star
   fires together: the score is one value, and running the tick left to right made
   five stars look like five separate things happening. */
@keyframes rating-tick {
  0% { transform: scale(1); }
  35% { transform: scale(0.8); }
  100% { transform: scale(1); }
}
.rating--flash .rating__fill svg { animation: rating-tick 380ms ease-out both; }

@media (prefers-reduced-motion: reduce) {
  .rating--flash .rating__fill svg { animation: none; }
  .rating__fill { transition: none; }
}

/* Transparent hit slices over the artwork of the stars. They're taller than the
   stars they cover: a 32px glyph is a fine thing to look at and a poor thing to
   hit, and vertical slop is free here — nothing sits directly above or below. */
.rating__hit {
  position: absolute;
  top: -0.4rem;
  bottom: -0.4rem;
  width: 20%;
  z-index: 1;
  padding: 0;
  border: none;
  background: none;
  cursor: pointer;
}
.rating__hit:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: var(--radius-1);
}

/* --- confirm modal -------------------------------------------------------- */

.confirm__question { font-size: var(--text-lg); }
.confirm__actions { justify-content: flex-end; }
/* Destructive, and the kit has no danger variant — adding one would mean editing
   a vendored file, so the primary button borrows the token here instead. */
.confirm__actions .btn--primary {
  background: var(--danger);
  border-color: var(--danger);
}
.confirm__actions .btn--primary:hover { filter: brightness(1.08); }

/* --- progress ------------------------------------------------------------- */

.progress { display: grid; gap: var(--space-2); max-width: 32rem; }
.progress__bar {
  height: 0.5rem;
  border-radius: var(--radius-full);
  background: var(--field);
  overflow: hidden;
}
.progress__fill {
  height: 100%;
  background: var(--accent);
  border-radius: var(--radius-full);
  transition: width var(--transition);
}
.progress__label { font-size: var(--text-sm); }

/* --- seasons + episodes --------------------------------------------------- */

/* One card holding the picker row and the episode list, replacing the stack of
   per-season cards. */
.seasons-panel {
  border-radius: var(--radius-3);
  background: var(--surface);
  box-shadow: var(--shadow-1);
  padding: var(--space-4);
}
/* Both columns, not just the text one. The poster is 200px of a 1100px container
   and it stops level with the shelf — everything below that was an empty gutter
   running the length of a thirteen-row list, with the content crowded into what
   was left. Spanning gives the list the width back and squares its left edge with
   the poster's, which is where the page's own margin is. Harmless on the narrow
   layout, where `.hero` is a single column and `1 / -1` is that column. */
.hero > .seasons-panel { grid-column: 1 / -1; }
/* Picker · count + mark-all · density. Wraps rather than compressing: at phone
   widths three control groups don't fit a row, and a squeezed picker is worse than
   a second line.

   The alignment has to survive that wrap, which is why it's `flex-end` plus one
   auto margin rather than the auto margin alone. A wrapped flex line packs to the
   start, so the density toggle dropped to the second row and sat at the far left,
   under nothing, with the whole row empty to its right — placed by default rather
   than by decision. Ending the line instead puts it directly under the mark-all
   tick, so the controls keep one right edge however many rows they take.

   Unwrapped nothing changes: the picker's auto margin eats all the free space
   before justify-content can see any, so the row is still picker-left with the
   count, tick and toggle packed against the far end. */
.seasons-panel__head {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: var(--space-3);
  margin-bottom: var(--space-3);
}
/* The containing block for the kit's `.dropdown__menu`, which is position:absolute
   — without this the menu resolves against whatever ancestor happens to be
   positioned and opens somewhere else entirely. `.dropdown` and `.select-menu` in
   the kit each do the same job for their own markup. */
/* The auto margin lives here, on the one item that should hold the left edge, so
   it does its job on whichever line the picker ends up sharing. */
.season-picker { position: relative; flex: none; margin-right: auto; }
/* Sized to its label instead of filling the row like a form field — it's a control
   in a toolbar here, not an input in a form. The floor is there so the trigger
   doesn't resize as you move between "Specials" and "Season 10" and drag the count
   and tick along with it. */
.season-picker__trigger {
  width: auto;
  min-width: 9rem;
  font-weight: var(--weight-medium);
}
/* That floor is also what used to break the head onto two lines. The four controls
   came to about five pixels more than a 390px phone has, and the picker was
   carrying roughly 34px of slack it wasn't using — the visible gap between the
   label and its chevron. 7rem still clears the longest season name, so the trigger
   keeps a stable width, and the row keeps the toggle where it belongs. Tightening
   the gaps buys the same again, which is what holds the line together down to
   about 360px. */
@media (max-width: 32rem) {
  .seasons-panel__head { gap: var(--space-2); }
  .season-picker__trigger { min-width: 7rem; }
}
.season-picker .dropdown__menu { max-height: 16rem; overflow-y: auto; }
/* The count sits at the right of each option, so the numbers form a column. */
.season-picker .dropdown__item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-4);
  white-space: nowrap;
}
.season-picker__count { font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
.season-picker__count--done { color: var(--success); font-weight: var(--weight-medium); }
.dropdown__item--active { background: var(--hover-tint); font-weight: var(--weight-medium); }

/* Rides the far end of the head row — pushed there by the picker's auto margin,
   not its own, so that when the toggle wraps away this block still finishes the
   line the picker started. */
.season__status { display: flex; align-items: center; gap: var(--space-2); flex: none; }
/* An outlined ring in both states — never filled. Unfinished it's a muted circle
   offering the action; complete, ring and glyph both go the success green, the
   same colour the count chip beside it takes. Only the hue changes between the
   two, so the control never grows or shrinks in weight as you toggle it. */
/* 1.5px, not the usual hairline: the ring has to weigh the same as the glyph
   inside it or the two read as separate objects. The kit draws `.btn--icon svg`
   at 1.15rem from a 24-unit viewBox, and the tick's stroke-width is 2 — so its
   painted stroke is 2/24 × 1.15rem, a shade over 1.5px. */
.season__status .btn {
  background: transparent;
  border: 1.5px solid var(--rule-strong);
  color: var(--text-muted);
}
.season__status .btn[aria-pressed="true"] {
  border-color: var(--success);
  color: var(--success);
}
.eps { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-1); }

/* The <li> is just the layout box — the label inside is the target. */
.ep { border-radius: var(--radius-2); }

/* Hover lights the CHECKBOX, not the row. Tinting the whole row grey did say
   "this is one target", but it said it by repainting the widest object on screen
   for a hit on a 1.15rem control — thirteen of them down a season, and the list
   flickered slab-to-slab as the pointer crossed it. Putting the signal on the
   checkbox points at the thing the click actually operates, and it still triggers
   from anywhere in the row, so the wide target survives.

   The ring is the kit's own :focus-visible treatment (`.check__mark`, primitives
   .css) — same offset, same --accent-soft — so hover and keyboard focus agree
   instead of inventing a second highlight, and the transition is already declared. */
@media (hover: hover) {
  .ep__row:hover .check__mark { box-shadow: 0 0 0 3px var(--accent-soft); }
}
/* Touch has no hover: the same ring on press, which is also the only feedback a
   tap gets between the finger going down and the fill wiping across. */
.ep__row:active .check__mark { box-shadow: 0 0 0 3px var(--accent-soft); }

/* One line per episode. Two lines across a thirteen-episode season made the
   season block taller than the screen, and it put the checkbox — the state, not
   the content — at the top of every row, so the list scanned as a column of grey
   boxes before it scanned as episodes.

   The kit's `.check` supplies the flex row, the gap and the cursor; only the
   sizing of the three slots belongs here. */
.ep__row {
  width: 100%;
  padding: var(--space-2) var(--space-3);
  gap: var(--space-3);
}
/* A fixed lane, right-aligned and tabular, so single and double digits stack into
   one column and the titles all start on the same edge. */
.ep__num {
  flex: none;
  width: 1.5rem;
  text-align: right;
  font-size: var(--text-sm);
  font-variant-numeric: tabular-nums;
}
/* Takes the slack and wraps rather than truncating: a title cut with an ellipsis
   is a title you can't read, and only the rare long one costs a second line. */
.ep__title { flex: 1; min-width: 0; font-size: var(--text-sm); line-height: 1.4; }
/* Watched rows dim their title. Driven by :has() off the real checkbox rather
   than a server-rendered class, because the row is no longer re-rendered on
   toggle — the browser owns the checkbox state, so the styling has to read it
   from the DOM. */
.ep:has(.check input:checked) .ep__title { color: var(--text-muted); }

/* Pinned right, tabular so the runtimes line up into their own column. */
.ep__meta {
  flex: none;
  font-size: var(--text-xs);
  font-variant-numeric: tabular-nums;
}
/* Date and runtime share the slot. Compact keeps the old either/or — one line has
   room for one number — and `:only-child` says exactly that: the date shows there
   only when there's no runtime to prefer, which is the unaired case it was already
   covering. Expanded shows both. */
.seasons-panel[data-density="compact"] .ep__date:not(:only-child) { display: none; }
/* The separator belongs to the date rather than sitting between the two, so it
   disappears with it in compact and never strands itself in front of a runtime. */
.ep__date:not(:only-child)::after { content: "·"; margin: 0 0.4em; }

/* --- episode density ------------------------------------------------------- */

/* Two icon buttons as one object: a shared hairline box, no gap, and a divider
   between them, so it reads as one control with two positions rather than two
   buttons that happen to be adjacent. */
.density {
  display: inline-flex;
  flex: none;
  border: 1px solid var(--rule);
  border-radius: var(--radius-full);
  overflow: hidden;
}
.density .btn {
  background: transparent;
  border: none;
  border-radius: 0;
  color: var(--text-muted);
}
.density .btn + .btn { border-left: 1px solid var(--rule); }
/* The selected side takes the accent — the same "this is the live one" signal the
   rest of the app uses, and the only thing that changes is colour, so neither
   button shifts weight as you flip between them. */
.density .btn[aria-pressed="true"] { color: var(--accent); background: var(--hover-tint); }

/* Both densities are always in the DOM; this is the switch. Compact is the default
   and matches what the list was before the still and overview existed, so the
   selector hides rather than shows — a row with no data for either (TMDB omits
   stills on unaired episodes) then needs no special case in either density. */
.seasons-panel[data-density="compact"] .ep__still,
.seasons-panel[data-density="compact"] .ep__synopsis { display: none; }

/* Expanded: the still takes a fixed lane on the left and the text column takes the
   slack. 11rem is what the panel's full width paid for — at 8.5rem the thumbnail
   was sized for a column that no longer exists, and the blurb beside it had more
   room than a blurb needs. */
.seasons-panel[data-density="expanded"] .ep {
  display: flex;
  align-items: flex-start;
  gap: var(--space-3);
  padding: var(--space-2);
}
/* Nothing needed for hover here: the signal lives on the checkbox in both
   densities, which is what makes it work on a tall card at all — the still and the
   blurb aren't targets, and a tint over them would have promised that they were. */

/* Four columns, declared once, and the overview starts on the title's — so the
   blurb's left edge IS the title's left edge, not a padding value picked to look
   close to it.

   The label can't just be a flex row here: it holds three of the four columns, so
   its widths would be its own business and the overview outside it could only ever
   guess where the title began. `subgrid` makes the label adopt the parent's tracks
   instead of defining its own, which is what puts both rows on one set of columns.
   Compact is untouched and stays the proven flex row — .ep__main is a plain block
   there, so none of this applies. */
.seasons-panel[data-density="expanded"] .ep__main { flex: 1; min-width: 0; }
/* The <li> carries the padding in this density, so the checkbox tops out level
   with the still. */
.seasons-panel[data-density="expanded"] .ep__row { padding: 0; }

/* Guarded, because without subgrid `grid-template-columns: subgrid` is a dropped
   declaration and the label would keep `display: grid` — stacking the checkbox,
   number, title and runtime into four vertical rows. Falling back to the flex row
   above costs only the overview's alignment. */
@supports (grid-template-columns: subgrid) {
  .seasons-panel[data-density="expanded"] .ep__main {
    display: grid;
    /* checkbox · title · meta. Three tracks, not four: the number has moved onto the
       still as a corner badge, which is both where the convention puts it and 2.25rem
       the blurb gets back — on a phone that was the difference between four wrapped
       lines and three. */
    grid-template-columns: 1.15rem 1fr auto;
    gap: var(--space-1) var(--space-3);
    align-items: center;
  }
  .seasons-panel[data-density="expanded"] .ep__row {
    display: grid;
    grid-column: 1 / -1;
    grid-template-columns: subgrid;
    align-items: center;
  }
  .seasons-panel[data-density="expanded"] .ep__synopsis {
    /* Second track to the end: under the title, running out through the meta column,
       because a blurb reflowed to stop short of the runtime wastes the line. */
    grid-column: 2 / -1;
    margin: 0;
    padding: 0;
  }
}

/* The number, moved onto the thumbnail. Absolute rather than a second element, so
   there's still one episode number in the markup — it sits in its own grid column in
   compact and lifts out of flow here, which is also why .ep__main can drop to three
   tracks (an out-of-flow child claims none).
   Anchored to the still's corner: the row's own padding is the offset, so the badge
   inset holds at either still width. */
.seasons-panel[data-density="expanded"] .ep { position: relative; }
.seasons-panel[data-density="expanded"] .ep__num {
  position: absolute;
  top: calc(var(--space-2) + 0.25rem);
  left: calc(var(--space-2) + 0.25rem);
  width: auto;
  padding: 0.05rem 0.3rem;
  border-radius: var(--radius-1);
  /* Its own dark chip, not the muted body colour: this sits on arbitrary
     photography, so it has to carry its own contrast rather than borrow the card's.
     Fixed values, not tokens — the backing is a scrim over an image in both themes,
     so it shouldn't flip with them. */
  background: rgba(0, 0, 0, 0.62);
  color: #fff;
  font-size: var(--text-xs);
  font-weight: var(--weight-medium);
  line-height: 1.4;
}
/* Watched rows dim their title; the badge shouldn't follow it down — it's an index,
   not a piece of content, and a faded number over a photo stops being readable. */
.seasons-panel[data-density="expanded"] .ep:has(.check input:checked) .ep__num { color: #fff; }

.ep__still {
  flex: none;
  /* An <img> is inline by default. As a flex item that never mattered — flex
     blockifies its children — but the stacked layout below lays it out in a line
     box, where it sits on the baseline and leaves room under itself for descenders
     that will never come. That phantom strip landed between a still and its own
     title, which is the one gap in the row that has to stay tight. */
  display: block;
  width: 11rem;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  /* Matches the `.ep` box radius, so the thumbnail sits inside the row's rounding
     instead of squaring off a corner the row has rounded. */
  border-radius: var(--radius-2);
  /* A filled box while it loads, and for the occasional still TMDB 404s — an empty
     img would collapse the text column left by 8.5rem mid-list.

     --control, not --field: `--field` is #ffffff in the light theme, the same value
     as the --surface this panel is painted on, so the box was invisible on exactly
     the theme most people use it in. --control is the kit's neutral control backing
     and is a step off the surface in both themes. */
  background: var(--control);
}
/* The no-still case: same box, glyph centred. No extra opacity — the fill is a
   mid-grey now, and dimming the glyph against it left nothing to see. */
.ep__still--blank {
  display: grid;
  place-items: center;
  color: var(--text-muted);
}
.ep__still--blank svg { width: 1.5rem; height: 1.5rem; }
/* The blurb, borrowing the hero synopsis's fade-and-expand (see `.hero__synopsis`
   in the narrow block below) at list scale. Two differences, both from being one of
   thirteen rather than one on the page: the clamp is tight enough to hold the row
   at the still's height, and the toggle is a two-word chip in the corner instead of
   a centred button.

   Positioned, because that chip lies over the tail of the text while clamped. */
.ep__synopsis { position: relative; display: grid; gap: var(--space-1); }
.ep__overview {
  margin: 0;
  font-size: var(--text-sm);
  line-height: 1.5;
  /* The line count is a geometry decision, not a reading one: title + gap + three
     lines comes to 5.41rem against the 11rem still's 6.19rem, so the still governs
     every row's height and the list keeps one rhythm however much text an episode
     has. Four would overshoot it and put the height back in TMDB's hands. It was
     two while the still was 8.5rem; the taller image is what bought the third line,
     and the stacked layout wanted three all along — so there's one value now
     instead of a wide one and a narrow one. */
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 3;
  line-clamp: 3;
  overflow: hidden;
}
/* The fade is a mask, not an overlay — same reason as the hero's: it fades the
   glyphs themselves, so it doesn't need to know what colour is behind them. Only
   the last line dissolves (the hero can afford to fade a third of six lines; two
   lines can't spare one), and only when something is really cut — a blurb that
   happens to fit exactly has nothing hidden and shouldn't look as though it does. */
.ep__synopsis.is-clamped .ep__overview {
  cursor: pointer;
  -webkit-mask-image: linear-gradient(to bottom, #000 80%, transparent 100%);
  mask-image: linear-gradient(to bottom, #000 80%, transparent 100%);
}
.ep__synopsis.is-open .ep__overview {
  display: block;
  -webkit-line-clamp: none;
  line-clamp: none;
}
/* Not the kit's .btn: a ghost button's padding and hover slab are more object than
   two words in a corner want to be, and the backing here has a job the kit's would
   overwrite. So it's a bare button that takes the accent — the same "this is the
   live thing" colour the rest of the app uses — with the kit's own focus ring.

   That backing is a gradient, not a flat fill: the chip sits on top of the line it
   is truncating, and a hard edge would cut a word in half. Fading --surface in from
   the left lets the sentence dissolve into the chip instead. */
.ep__more {
  justify-self: end;
  border: 0;
  padding: 0 0 0 1.5rem;
  font: inherit;
  font-size: var(--text-xs);
  font-weight: var(--weight-medium);
  line-height: 1.5;
  color: var(--accent);
  background: linear-gradient(to right, transparent, var(--surface) 1.25rem);
  cursor: pointer;
}
.ep__more:focus-visible {
  outline: none;
  border-radius: var(--radius-1);
  box-shadow: 0 0 0 3px var(--accent-soft);
}
/* Over the faded tail, so the toggle costs the row no height — which is the whole
   point of clamping. Once open there is no tail to lie on and it drops into flow
   under the text, keeping its right edge so it doesn't slide sideways as you open
   it. */
.ep__synopsis.is-clamped .ep__more { position: absolute; right: 0; bottom: 0; }

/* Tall cards need a divider that a 1px gap can't supply. The rows carry their own
   padding, so dropping the list gap and ruling between them gives a line that runs
   the full width of the card instead of floating inside a gutter. */
.seasons-panel[data-density="expanded"] .eps { gap: 0; }
.seasons-panel[data-density="expanded"] .ep + .ep { border-top: 1px solid var(--rule); }
/* The row's rounding is for compact, where it's the shape of a hover target. Here
   the row paints no background at all, so the only thing the radius can reach is
   this divider — and a border-top on a rounded box arcs upward at both ends, which
   reads as the top edge of yet another card rather than as a line between two rows. */
.seasons-panel[data-density="expanded"] .ep { border-radius: 0; }

/* Phones stack the card: still, then the title row, then the blurb. A side-by-side
   thumbnail has to shrink to about 5rem at this width, which is too small to be worth
   looking at and still narrow enough to squeeze the title into two wrapped lines — it
   cost the layout on both sides of the gap.

   Full width is what it costs in height, and that's affordable here in a way it
   wasn't before: compact density now exists for scanning, so expanded is free to be
   the browsing view rather than a compromise between the two. The dividers between
   rows are what make it legible — the reason to avoid a leading image used to be that
   it read as belonging to the episode above it. */
@media (max-width: 32rem) {
  /* Out to the panel's edges, then back in. The row was inset twice before it drew
     anything — once by the panel's padding, again by its own — so a stacked still
     ended up floating in a white frame with air on all four sides, which is the
     shape of a card. Thirteen of those inside one card read as cards on a card. The
     negative margin cancels exactly the panel's padding, so the row's box IS the
     panel's width: the still below can reach the edge, and the divider above runs
     the whole way across instead of stopping in a gutter.

     Vertical padding goes up at the same time. Intra-row spacing was space-2 (still
     to title) and inter-row was two of them, and against a hairline that was too
     close to call — each image read as capping the episode above it as much as
     introducing its own. Whitespace is the whole separator here now (see the
     dropped divider below), so space-4 top and bottom puts 2rem between one episode
     and the next against the 0.5rem between a still and its own title: a 4:1 ratio,
     which is enough for proximity alone to say which title an image belongs to. */
  .seasons-panel[data-density="expanded"] .ep {
    display: block;
    margin-inline: calc(-1 * var(--space-4));
    padding: var(--space-4);
  }
  /* Edge to edge and square. A radius here would put the frame back on: with the
     image spanning the full width there is no card for it to be the inside of, and
     rounding it would only re-detach it from the row it belongs to. */
  .seasons-panel[data-density="expanded"] .ep__still {
    width: calc(100% + 2 * var(--space-4));
    margin-inline: calc(-1 * var(--space-4));
    margin-bottom: var(--space-2);
    border-radius: 0;
  }
  /* The chip follows the still out to the edge. Both offsets want to be space-2 in
     from the still's top-left corner, and they read differently only because the
     origin — the row's padding box, i.e. the row's own edges, since it has no
     border — sits in a different place on each axis. Horizontally the still's
     negative margin has already pulled it back onto that edge, so space-2 is the
     inset outright. Vertically the still starts below the row's padding, so the
     padding has to be added back before the inset. */
  .seasons-panel[data-density="expanded"] .ep__num {
    top: calc(var(--space-4) + var(--space-2));
    left: var(--space-2);
  }
  /* No divider. It earns its keep on a wide row, where the still is an inset
     thumbnail and nothing else marks where one episode ends — but a full-bleed
     image already draws a hard edge across the whole card, and a rule a few pixels
     above it is a second boundary for the same seam. What you saw was the strip of
     white trapped between them, which reads as a card's top padding rather than as
     space between two rows. So the image's own edge does the job, and the 2rem
     above it is all the separation the list needs. */
  .seasons-panel[data-density="expanded"] .ep + .ep { border-top: 0; }
  /* Full width under the whole row, not hanging off the title. The indent that
     organises a wide row is just a dead gutter here — stacked, the blurb wants every
     character it can get more than it wants its left edge aligned to the title's. */
  @supports (grid-template-columns: subgrid) {
    .seasons-panel[data-density="expanded"] .ep__synopsis { grid-column: 1 / -1; }
  }
}

/* --- settings ------------------------------------------------------------- */

.setting {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-5);
  flex-wrap: wrap;
  padding: var(--space-2) 0;
}
.setting__label { display: grid; gap: var(--space-1); }
.setting__hint { font-size: var(--text-sm); max-width: 44ch; }

/* Legally required, not interesting — small and quiet at the foot of the page. */
.attribution {
  margin: var(--space-6) 0 0;
  font-size: var(--text-xs);
}

/* --- landing -------------------------------------------------------------- */

.landing {
  display: grid;
  justify-items: center;
  gap: var(--space-5);
  padding: var(--space-8) var(--space-5);
  text-align: center;
}
.landing__title { margin: 0; max-width: 18ch; }
.landing__sub { margin: 0; max-width: 48ch; line-height: 1.6; }

/* A literal white chip, not a token: Google's mark is only allowed on white or a
   neutral, and the button behind it is whatever accent the user picked. The chip
   makes the button correct at every accent instead of at one. */
.btn__gmark {
  display: grid; place-items: center; flex: none;
  width: 1.3rem; height: 1.3rem;
  background: #fff; border-radius: var(--radius-full);
}
.btn__gmark svg { display: block; width: 0.85rem; height: 0.85rem; }

/* The email escape hatch reads as a link, not a second primary action competing
   with Google — so it drops the button chrome and keeps only the affordance. */
.landing__toggle {
  border: none; background: none; padding: 0; font: inherit; cursor: pointer;
  color: var(--text-muted); text-decoration: underline; text-underline-offset: 0.2em;
  transition: color var(--transition);
}
.landing__toggle:hover { color: var(--text); }

/* The panel is the one left-aligned thing on a centred page: labels above their
   fields have to start on the same edge to scan as a form at all. */
.landing__email {
  display: grid; gap: var(--space-4);
  width: 100%; max-width: 20rem; text-align: left;
}
/* Equal halves when both labels fit, stacked full-width when they don't. The
   nowrap is the point: a button is a single target and "Create account" broken
   across two lines reads as two. `.row` already wraps, so it stacks instead. */
.landing__actions { justify-content: stretch; }
.landing__actions .btn { flex: 1 1 8rem; white-space: nowrap; }

/* --- narrow screens ------------------------------------------------------- */

@media (max-width: 720px) {
  /* The poster column can't hold its 200px next to text on a phone; stack, and
     cap the art so it doesn't eat the whole first screen. The cap is on the
     artwork rather than the column, so the stars underneath aren't squeezed into
     9rem along with it. */
  .hero { grid-template-columns: 1fr; }
  .hero__art img,
  .hero__art .tile__blank { max-width: 9rem; }

  /* A touch nudge over the pointer size — 36px rather than 32px — and no more.
     44px would be the textbook target, but it makes the row half again as wide
     as the poster it hangs under, and a control that dwarfs the artwork reads as
     a mistake. The hit slices are taller than the stars anyway (see
     `.rating__hit`), so the shortfall is only on one axis.

     The block sits at the left edge, under the poster: the column is the full
     page width here, so centring it would strand the stars in the middle of the
     screen with the artwork off to one side. */
  .rating { justify-self: start; }
  .rating__stars { --star: 2.25rem; }

  /* Out of flow and alongside the stars, not under them. Stacked, the artwork
     column sits directly above the rest of the hero, so a "Clear" that appears
     and disappears with the score would add and remove a line and shunt the whole
     page down and back every time you rated something. Bottom-aligned rather than
     centred: a star is a tall glyph with a point on top, so its optical centre
     sits low and a vertically centred label reads as floating. */
  .rating__clear {
    position: absolute;
    left: 100%;
    bottom: 0;
    margin-left: var(--space-2);
  }

  /* A synopsis can run to a dozen lines and push the shelf switch off the
     screen. Clamp it, and fade the tail so the cut reads as "there's more"
     rather than as a sentence that stops mid-thought.

     The fade is a mask, not a gradient overlay: this text sits on backdrop art
     on some pages and flat --bg on others, and fading the glyphs themselves to
     transparent doesn't have to know which. It's applied only when the text
     really is truncated (`.is-faded`, set from a measurement) — a synopsis that
     happens to fill exactly the clamp has nothing hidden and shouldn't look as
     though it does. */
  .hero__overview {
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 6;
    line-clamp: 6;
    overflow: hidden;
  }
  /* The clamped paragraph is itself the tap target — the toggle is the label for
     what tapping does, not the only way to do it. */
  .hero__synopsis.is-clamped .hero__overview {
    cursor: pointer;
    -webkit-mask-image: linear-gradient(to bottom, #000 30%, transparent 90%);
    mask-image: linear-gradient(to bottom, #000 30%, transparent 90%);
  }
  .hero__synopsis.is-open .hero__overview {
    display: block;
    -webkit-line-clamp: none;
    line-clamp: none;
  }

  /* Centred on the text and lying over the part of it that has faded out, so the
     toggle occupies space the paragraph has already given up rather than adding a
     line below it. Only while clamped: once open there is no fade, and the same
     position would cover a line you can actually read — so it drops back into
     flow underneath.

     Centred by auto margins, not by `left: 50%` + `translateX(-50%)`. The kit's
     .btn transitions `transform`, so the translate would animate away as the
     class dropped and the button would visibly slide sideways on every toggle —
     auto margins move it with no property the transition can catch. */
  .hero__synopsis.is-clamped .btn {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    width: max-content;
    margin-inline: auto;
  }
  .tiles { grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: var(--space-3); }
  .searchbar { flex-wrap: wrap; }
  .page-header { align-items: flex-start; }
}
