/* ============================================================
   Blutterbunged mobile webapp — normal site colours
   (dark/light theming comes later; tokens are ready for it)
   ============================================================ */
/* ---------------- the hidden attribute --------------------------------
   `hidden` is only display:none in the BROWSER's stylesheet, so any
   author rule that sets display at all beats it — and then JS setting
   .hidden = true silently does nothing. That is how the Surprise page
   came to offer "Read review" for a title that has none: the button is
   .btn-primary { display: inline-flex }, so hiding it never took, and it
   kept the previous film's id and opened that instead. Rather than
   patching each element as it bites, make the attribute mean what it
   says everywhere. */
[hidden] { display: none !important; }

:root {
  --bg: #0e0d0b;
  --bg2: #16140f;
  --card: #1c1913;
  --line: #2b2820;
  --fg: #f0ece2;
  --fg2: #b5afa2;
  --dim: #837d70;
  --accent: #c9843a;          /* site gold */
  --accent-ink: #14100a;      /* text on gold */
  --star-on: #f5c518;         /* site star gold */
  --star-off: rgba(255, 255, 255, 0.3);
  /* ---- iPhone safe areas ----
     Installed to the home screen the app runs full-screen under the
     status bar and the home indicator (black-translucent status bar),
     where these insets become ~47-59px top and ~34px bottom. In a
     browser tab they are 0, which is why the two looked so different:
     anything measured from a screen edge without them landed under the
     clock or behind the home bar. Held in variables both so the intent
     is legible and so the layout can be TESTED at real inset values by
     overriding them. */
  --sat: env(safe-area-inset-top, 0px);
  --sab: env(safe-area-inset-bottom, 0px);
  /* The strip the tab bar's CONTENT keeps clear of the bottom edge — the
     full home-indicator inset, as native tab bars use. An earlier
     release tried this and it looked like the bar had floated up off the
     screen, but that was a different problem: the window itself stopped
     59px short, so the bar's background ended early too. Now the
     background reaches the bottom edge and only the icons are held off
     it, which is what the indicator needs. */
  --sab-keep: max(10px, var(--sab));
  --tabbar-h: 62px;
  /* The bar's own height PLUS the strip kept clear beneath it — but no
     taller than it needs to be. 62px is the browser bar, most of which
     is breathing room around a 35px block of icon and label; adding the
     whole home-indicator strip on top of that left a band of empty bar
     ABOVE the icons as well as below. 46px is enough room for the block
     itself, so on a phone the bar is 46 + 34 = 80 rather than 96, and in
     a browser the max() keeps it at exactly the 62 it always was. */
  --tabbar-total: max(var(--tabbar-h), calc(46px + var(--sab-keep)));
  --topbar-h: 72px;
  --font-display: "Oswald", "Arial Narrow", "Helvetica Neue Condensed", sans-serif;
  /* Inter, not Josefin Sans. Josefin is a geometric DISPLAY face — very
     small x-height, thin even strokes, wide letterforms — which is why a
     paragraph of it on a dark background is tiring to read. Inter was
     drawn for screens: tall x-height, open apertures, and a weight that
     holds up in light-on-dark, where thin type visually erodes. */
  --font-body: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}

* { box-sizing: border-box; margin: 0; padding: 0; }

html, body {
  height: 100%;
  background: #000;
  overscroll-behavior: none;
}

body {
  font-family: var(--font-body);
  color: var(--fg);
  -webkit-font-smoothing: antialiased;
}

/* Pinned to the viewport rather than sized by 100dvh. The tab bar hangs
   off this element's bottom edge, so if the unit under-reports the
   screen — which is exactly what a home-screen web app is prone to — the
   bar stops short of the bottom and no amount of safe-area arithmetic
   inside it can help. top/bottom:0 on a fixed element is the viewport by
   definition, with nothing to under-report. */
#app {
  position: fixed;
  top: 0; bottom: 0; left: 0; right: 0;
  max-width: 480px;
  margin: 0 auto;
  background: var(--bg);
  overflow: hidden;
}

button { font-family: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
a { color: inherit; text-decoration: none; }
img { display: block; }

/* ---------------- views ---------------- */
.view {
  position: absolute;
  inset: 0;
  bottom: var(--tabbar-total);
  overflow: hidden;
}
.view[hidden] { display: none !important; }

/* The header is the same in a browser tab and installed to a home
   screen. It was special-cased for a while — hidden entirely, then shown
   only on Latest, and never unfolding — and every one of those rules had
   to be kept in step with the browser's by hand. Collapsed the badge is
   a 52px reel, which is little enough to carry everywhere. */

/* ---------------- persistent global header (logo, every main view) ----------------
   Floats above view content at a fixed position (absolute within #app, which
   already fills the viewport), left-aligned. No banner/gradient behind it —
   just a strong drop-shadow on the wordmark itself, so it reads over
   full-bleed art (feed/hero/surprise) as well as plain backgrounds without
   darkening a whole strip across the top. pointer-events is off on the
   wrapper (so it never blocks taps on content underneath, e.g. the surprise
   filter button) and back on for the logo link itself. */
.app-topbar {
  position: absolute;
  top: 0; left: 0; right: 0;
  z-index: 25;
  height: calc(var(--topbar-h) + var(--sat));
  /* Top gap matches the 16px left margin exactly (plus the notch safe
     area on phones). */
  padding: calc(var(--sat) + 16px) 16px 0;
  display: flex;
  align-items: flex-start;
  pointer-events: none;
}
.app-topbar .brand-badge { pointer-events: auto; }

/* ---------------- the brand badge (reel + wordmark) ----------------
   A projector reel that unfolds sideways into the wordmark. Collapsed it
   is just the reel, so on Latest and Surprise — where the header sits on
   top of a full-screen poster — the artwork keeps almost all of its top
   corner. The pill's width is set from JS once the fonts have loaded
   (see brandBadge in app.js): the open width depends on how wide
   "Blutterbunged" actually renders, which CSS cannot know. */
.brand-badge {
  position: relative;
  display: inline-flex;
  align-items: center;
  /* Tall enough for the two lines and the rule between them with a
     little air: the block itself measures 43px. */
  height: 52px;
  /* Starts collapsed — JS replaces this with the measured width. Without
     it the pill would paint at full width for one frame before the
     measurement lands. */
  width: 52px;
  padding: 0;
  border: 1.5px solid transparent;
  border-radius: 26px;
  overflow: hidden;
  white-space: nowrap;
  cursor: pointer;
  /* Gold rim: the gradient paints the border box, the near-black paints
     the padding box, so the ring reads as a hairline of light on the
     dark artwork rather than a filled shape. */
  background:
    linear-gradient(150deg, rgba(20, 17, 12, 0.92), rgba(10, 9, 7, 0.92)) padding-box,
    linear-gradient(135deg, #e8b06a, var(--accent) 55%, #8a5722) border-box;
  transition: width 0.9s cubic-bezier(0.65, 0, 0.35, 1),
              padding-right 0.9s cubic-bezier(0.65, 0, 0.35, 1),
              box-shadow 0.5s ease;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.6);
}
.brand-badge.open { box-shadow: 0 2px 14px rgba(0, 0, 0, 0.7), 0 0 20px rgba(201, 132, 58, 0.28); }
.brand-badge .reel {
  flex: 0 0 auto;
  position: relative;
  display: block;
  width: 44px; height: 44px;
  /* Even margins, so shut the pill is a circle around the reel rather
     than a slightly clipped one. */
  margin: 0 2.5px;
}
/* The drawing FILLS its box. buildReel() makes a fixed-size SVG (44px),
   and the desktop masthead sizes the span at 56px — so the drawing sat in
   the top-left corner of a bigger box while .reel-spin rotated about that
   box's centre. The reel appeared to wobble because it was orbiting a
   point that was not its own middle. */
.brand-badge .reel .reel-spin,
.brand-badge .reel svg,
.rotate-note .reel svg { width: 100%; height: 100%; }

/* display:block on the svg matters more than it looks: as an inline box
   it sits on a text baseline, which left a few pixels of descender space
   under it — the reel then hung high in its own box and turned around a
   centre that was not its own. */
.brand-badge .reel svg { display: block; }
/* Still at rest; turns all the while it is open, and finishes the turn
   it is on as it closes (the wind-down is driven from JS). */
.brand-badge .reel .reel-spin { display: block; transform: rotate(0deg); }
.brand-badge.open .reel .reel-spin { animation: bb-reel-spin 4.5s linear infinite; }
@keyframes bb-reel-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }

.brand-block {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  justify-content: center;
  margin-left: 12px;
  /* Optical: the wordmark's cap height sits lower in its line box than
     the reel's centre, so the pair reads low without this. */
  margin-top: -3px;
  opacity: 0;
  transform: translateX(-6px);
  transition: opacity 0.42s ease 0.4s, transform 0.42s ease 0.4s;
}
.brand-badge.open .brand-block { opacity: 1; transform: translateX(0); }
.wordmark {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 19px;
  letter-spacing: 0.4px;
  line-height: 1;
  color: var(--fg);
}
/* The gold rule the wordmark always carried, now between the two lines. */
.brand-rule {
  width: 100%;
  height: 1.5px;
  margin: 4px 0 2px;
  background: linear-gradient(90deg, var(--accent), rgba(201, 132, 58, 0));
}
.brand-tagline {
  font-family: var(--font-display);
  font-weight: 500;
  font-style: italic;
  letter-spacing: 0.4px;
  line-height: 1;
  color: var(--accent);
  /* Sized from JS to match the wordmark's width exactly. */
  font-size: 11px;
}

/* ---------------- collections scroll area ---------------- */
.collections-scroll {
  height: 100%;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
}
.collections-scroll::-webkit-scrollbar { display: none; }
.home-end { height: 24px; }

/* ---------------- hero (editor's picks) ---------------- */
/* Starts below the persistent Blutterbunged logo instead of underneath
   it — the padding pushes the first row of content clear of the topbar
   (content still scrolls up under the logo afterwards, like the rest). */
.hero-wrap { position: relative; padding-top: calc(var(--topbar-h) + var(--sat) + 14px); }
.hero-track {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  gap: 12px;
  padding: 2px 16px 0;
}
.hero-track::-webkit-scrollbar { display: none; }

.hero-card {
  position: relative;
  flex: 0 0 100%;
  scroll-snap-align: center;
  /* Wide, cinematic card — sized by its 16:9 backdrop artwork rather
     than a fixed height. */
  aspect-ratio: 16 / 9;
  border-radius: 16px;
  overflow: hidden;
  background: var(--card);
  isolation: isolate;
}
.hero-card .hero-img {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  z-index: -1;
}
.hero-card::after {
  content: "";
  position: absolute; inset: 0;
  background: linear-gradient(180deg, rgba(10, 9, 7, 0.05) 30%, rgba(10, 9, 7, 0.62) 68%, rgba(10, 9, 7, 0.88) 100%);
  z-index: 0;
}
.hero-body {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  z-index: 1;
  padding: 16px;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 7px;
}
.hero-kicker {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 2.5px;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.85);
}
.hero-title {
  font-family: var(--font-display);
  font-weight: 700;
  /* Scaled to the shorter 16:9 card (was 40px on the old 340px-tall one). */
  font-size: clamp(24px, 7.5vw, 32px);
  line-height: 0.98;
  letter-spacing: 0.3px;
  color: #fff;
  text-shadow: 0 2px 14px rgba(0, 0, 0, 0.45);
}
.hero-meta { display: flex; align-items: center; gap: 9px; }
.hero-tagline {
  font-style: italic;
  font-size: 14px;
  color: rgba(255, 255, 255, 0.88);
}

/* ---- ratings ----
   EXPERIMENT: reels instead of stars, switched by RATING_MARK in app.js.
   Everything under .stars-reel applies only when that is on; the star
   rules beside it are untouched, so putting it back is one word. */
.stars-reel {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  letter-spacing: 0;
}
.stars-reel > span {
  position: relative;
  display: inline-flex;
  line-height: 0;
}
/* Sized in em, so a rating keeps whatever size the block around it set. */
.stars-reel svg { display: block; width: 1em; height: 1em; fill: currentColor; }
/* The star's half is a pseudo-element, which cannot hold an SVG. */
.stars-reel .half::before { content: none; }
/* 57%, not 50%. A mark clipped down its exact middle reads as less than
   half — the widest part of both a reel and a star is at the centre, so
   the lit side tapers away just where the eye is measuring it. A couple
   of pixels past the middle is what looks like half. */
.stars-reel .half-fill {
  position: absolute;
  left: 0; top: 0;
  width: 57%;
  overflow: hidden;
  color: var(--star-on);
}

/* stars */
.stars { font-size: 15px; letter-spacing: 2px; color: var(--star-off); white-space: nowrap; }
.stars .on { color: var(--star-on); }
.stars .half { position: relative; display: inline-block; }
.stars .half::before {
  content: "★";
  position: absolute; left: 0; top: 0;
  /* Same reasoning as .stars-reel .half-fill above. */
  width: 57%;
  overflow: hidden;
  color: var(--star-on);
}

/* watchlist / bookmark button (over images, and the review page's own
   header) — same treatment everywhere on the site: no circle backdrop,
   just the icon with a strong drop-shadow for readability over any
   artwork, filled gold when already on the watchlist. See .icon-btn
   .ico-bm below, which this matches. */
.wl-btn {
  position: absolute;
  top: 14px; right: 14px;
  z-index: 2;
  width: 34px; height: 34px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: none;
  border: 0;
}

/* ---------------- dots ---------------- */
.dots {
  display: flex;
  justify-content: center;
  gap: 7px;
}
.dots-hero { padding: 12px 0 4px; }
.dot-btn {
  width: 6px; height: 6px;
  border-radius: 3px;
  background: rgba(255, 255, 255, 0.28);
  padding: 0;
  transition: all 0.2s ease;
}
.dot-btn.active { width: 18px; background: var(--accent); }

/* ---------------- rails ---------------- */
.rail-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  padding: 20px 16px 10px;
}
.rail-head h2 {
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 2.4px;
  text-transform: uppercase;
  color: var(--dim);
}
.see-all {
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 1.4px;
  text-transform: uppercase;
  color: var(--accent);
}
.rail {
  display: flex;
  gap: 12px;
  overflow-x: auto;
  padding: 16px 16px 0;
  margin-top: -16px;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
}
.rail::-webkit-scrollbar { display: none; }

/* ---- drag-to-scroll cursor (mouse only; see addRailDrag in app.js) ----
   Was wide-layout-only, matching the arrow buttons it used to be
   bundled with — but addRailDrag itself now wires on every layout (a
   mouse has no native drag gesture at any width), so the cursor
   feedback needs to be everywhere it does. */
.rail, .feed-strip, .hero-track, .st-track { cursor: grab; }
.st-track img,
.rail img, .feed-strip img, .hero-track img,
.rail .poster-card, .rail .still-card, .hero-track .hero-card {
  -webkit-user-drag: none;
  user-select: none;
}
.rail.dragging, .feed-strip.dragging, .hero-track.dragging, .st-track.dragging {
  cursor: grabbing;
  scroll-snap-type: none; /* a snap point fighting the pointer mid-drag felt like stutter */
  user-select: none;
}

/* Bart, 16 Aug 2026: add title + reel rating under the Latest-feed
   posters on desktop, matching the Collections rails -- see the wide
   layout media query below for the >=1024px override. Hidden here by
   default so mobile (which never gets that media query) stays exactly
   as it was: bare posters, no caption. */
.feed-strip .poster-title,
.feed-strip .poster-stars { display: none; }

/* Landscape "stills" rail — the first genre collection renders as a
   hero-like row of 16:9 backdrop cards, sized like the top hero row
   (full width, one per screen). */
.rail-stills { scroll-snap-type: x mandatory; }
.still-card {
  position: relative;
  flex: 0 0 100%;
  scroll-snap-align: center;
  aspect-ratio: 16 / 9;
  border-radius: 16px;
  overflow: hidden;
  background: var(--card);
  isolation: isolate;
}
.still-card img {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  z-index: -1;
}
.still-card::after {
  content: "";
  position: absolute; inset: 0;
  background: linear-gradient(180deg, rgba(10, 9, 7, 0) 40%, rgba(10, 9, 7, 0.55) 72%, rgba(10, 9, 7, 0.9) 100%);
  z-index: 0;
}
.still-body {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  z-index: 1;
  padding: 10px 12px;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 3px;
}
.still-title {
  font-size: 16px;
  font-weight: 700;
  line-height: 1.2;
  color: #fff;
  text-shadow: 0 1px 6px rgba(0, 0, 0, 0.7);
  display: -webkit-box;
  -webkit-line-clamp: 1;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* 30% larger than the original 106×152 — more inviting to explore. */
.poster-card { flex: 0 0 138px; }
.poster-card { transition: transform 0.2s ease; }
.poster-card:hover { transform: translateY(-6px) scale(1.06); }
.poster-thumb {
  position: relative;
  width: 138px; height: 198px;
  border-radius: 10px;
  overflow: hidden;
  background: var(--card);
}
.poster-thumb img { width: 100%; height: 100%; object-fit: cover; }
.poster-title {
  margin-top: 7px;
  font-size: 13px;
  font-weight: 600;
  line-height: 1.25;
  color: var(--fg);
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.poster-stars { margin-top: 3px; font-size: 11px; letter-spacing: 1.5px; }

/* ---------------- home feed ---------------- */
#view-home { background: #000; }
.feed {
  height: 100%;
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
  /* Only horizontal touch-panning is native here — a vertical drag (the
     swipe-up-for-more gesture) is handed to JS immediately instead of the
     browser spending time deciding what the gesture is, which is what
     made swipe-up unreliable except very close to the handle. */
  touch-action: pan-x;
}
.feed::-webkit-scrollbar { display: none; }

.feed-slide {
  position: relative;
  flex: 0 0 100%;
  height: 100%;
  scroll-snap-align: center;
  scroll-snap-stop: always;
  overflow: hidden;
  isolation: isolate;
}
/* No-op on the phone: the blur/poster it wraps stay position:absolute
   against .feed-slide exactly as if this element weren't here at all.
   Only the wide layout (min-width:1024px, below) turns it into a real
   box, so the Ken Burns zoom has something of its own to clip to. */
.feed-art { display: contents; }
/* A 2:3 poster is a much squarer shape than a phone screen, so filling
   the frame with object-fit:cover threw away ~29% of the poster's WIDTH
   before the zoom even started. The poster is now shown whole
   (object-fit:contain), and a blurred, darkened copy of the same artwork
   fills the rest of the frame behind it — full-bleed look, nothing
   cropped. The Ken Burns zoom runs on the blurred layer, so the sharp
   poster can never drift out of frame. */
/* Sized to the FULL WIDTH of the screen at rest, its natural height
   following from that — so left and right sit exactly on the poster's
   own edges with nothing cropped and no blur down the sides. Anchored to
   the top, so the leftover space (and the blurred fill) is at the
   bottom. A poster too tall to fit is bottom-anchored instead by
   applyFaceFraming, putting that unavoidable crop at the top. */
.feed-slide .feed-img {
  position: absolute;
  left: 0; top: 0;
  width: 100%; height: auto;
  z-index: -1;
}
/* The poster rarely fills the screen — below it is the blurred copy of
   itself — and the join between the two showed as a hard horizontal
   line. Where that line falls depends on the poster's shape and the
   height of the screen, so a fixed gradient painted over the SLIDE can
   only ever cover it on some phones (installed to a home screen there is
   no browser chrome, the screen is taller, and the seam sat well clear
   of the scrim). Feathering the poster's own bottom edge fixes it
   wherever it lands: the picture dissolves into its own blur. */
.feed-slide .feed-img,
.sur-bg {
  -webkit-mask-image: linear-gradient(180deg, #000 calc(100% - 130px), rgba(0, 0, 0, 0) 100%);
  mask-image: linear-gradient(180deg, #000 calc(100% - 130px), rgba(0, 0, 0, 0) 100%);
}
.feed-slide .feed-blur,
.sur-blur {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  z-index: -2;
  /* Scaled up so the blur's own soft edge never reaches the frame. */
  transform: scale(1.15);
  filter: blur(34px) brightness(0.45) saturate(1.25);
}
.feed-slide::after {
  content: "";
  position: absolute; inset: 0;
  /* Nothing across the top, and nothing across the bottom either: the
     text block below carries its own shading now, and two of them would
     double up. This is left only as the layer the text sits above. */
  background: none;
  z-index: 0;
}

.feed-actor {
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 2.4px;
  text-transform: uppercase;
  color: var(--accent);
  text-shadow: 0 1px 2px rgba(0, 0, 0, 1), 0 2px 6px rgba(0, 0, 0, 0.95), 0 4px 18px rgba(0, 0, 0, 0.9), 0 0 34px rgba(0, 0, 0, 0.75);
}
.feed-title {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(38px, 12vw, 52px);
  line-height: 0.98;
  color: #fff;
  text-shadow: 0 2px 16px rgba(0, 0, 0, 0.5);
}
.feed-tagline {
  font-style: italic;
  font-size: 14px;
  color: rgba(255, 255, 255, 0.9);
}
/* A bit of extra breathing room between the tagline and the meta/rating
   line, on top of the regular gap between the overlay's children. */
.feed-summary {
  margin-top: 6px;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 8px;
}
.feed-meta {
  font-size: 12.5px;
  color: rgba(255, 255, 255, 0.82);
}
.feed-rating { display: flex; align-items: center; gap: 8px; }
.feed-rating .stars { font-size: 16px; }
.feed-review-text {
  font-size: 15px;
  line-height: 1.5;
  color: rgba(255, 255, 255, 0.85);
}

/* ---------------- feed text (actor, title, tagline, meta, rating) ----------------
   Always visible by default on every poster. The full review lives
   inside .feed-extra, collapsed to 0 height by default; swiping up or
   tapping the poster grows it open. Because this whole block is
   anchored to the bottom of the poster (bottom: 0) and sized to its own
   content, growing .feed-extra pushes the block's top edge upward -- so
   the always-visible text above visibly rises as the review appears
   beneath it. The same gesture collapses it again; see the pointer
   handling in app.js. */
.feed-text {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  z-index: 1;
  max-height: 92%;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 8px;
  /* The bottom padding clears the page dots, which sit 20px up from the
     bottom edge. At 24px the last line all but touched them (a 4px gap,
     narrower than the 8-14px between the lines above it); 36px puts a
     clear 16px between the text and the dots. The top padding is what
     the shading ramps up across, so the first line (the actor's name)
     starts on shading that has already arrived rather than on bare
     poster. See the shared .feed-text/.sur-body shading rule below. */
  padding: 64px 18px 36px;
  overflow-y: auto;
  scrollbar-width: none;
  /* Only horizontal panning here, matching the ancestor .feed -- this
     block can cover most of the poster once a full review is expanded,
     and a touch-action that allowed native vertical panning inside it
     (as a previous version did) fought with the swipe-to-collapse
     gesture below (the browser would sometimes claim the vertical drag
     for its own scrolling and never hand app.js a clean pointerup,
     leaving the panel stuck open) and, since intersecting pan-x with
     pan-y allows neither, also silently blocked horizontal feed
     navigation while the panel was open. Keeping this pan-x-only means
     every vertical gesture is fully driven by the pointer handling in
     app.js (reliable open/close either way) while horizontal swipes
     still pass straight through to the native feed scroller. */
  touch-action: pan-x;
}

/* ---------------- the text's own shading ----------------
   ONE rule for both landing pages — Latest (.feed-text) and Surprise
   (.sur-body) — because they are the same design and drifted apart once
   already when each carried its own copy.

   The shading belongs to the TEXT, not to the poster. Pinned to the
   slide it covered a fixed band, so swiping the review open lifted the
   words out of it and onto bare poster — unreadable over a pale one.
   Attached here it grows and shrinks with the panel, always sitting
   under whatever is being read.

   The stops are in PIXELS, not percentages: the fade has to be the same
   short ramp whether the panel is one line tall or fills the screen,
   which a percentage cannot do. It reaches roughly 70% dark by 64px,
   which is exactly the panel's top padding — so the first line, the
   actor's name in small caps, sits on shading that has already arrived
   instead of on the last of the fade. Below that it holds flat at 90%.

   background-attachment: local makes the gradient scroll and extend
   with this box's own content rather than being clipped to whatever was
   initially visible, so it reaches the bottom of a long, scrolled
   review with no gap or repeat. */
.feed-text,
.sur-body {
  background: linear-gradient(180deg,
    rgba(8, 7, 5, 0) 0,
    rgba(8, 7, 5, 0.10) 16px,
    rgba(8, 7, 5, 0.30) 34px,
    rgba(8, 7, 5, 0.52) 50px,
    rgba(8, 7, 5, 0.70) 64px,
    rgba(8, 7, 5, 0.82) 82px,
    rgba(8, 7, 5, 0.9) 104px,
    rgba(8, 7, 5, 0.9) 100%);
  background-repeat: no-repeat;
  background-attachment: local;
}
.feed-text::-webkit-scrollbar { display: none; }
.feed-more-hint {
  align-self: center;
  width: 28px; height: 3px;
  margin-top: 2px;
  border-radius: 2px;
  background: rgba(255, 255, 255, 0.4);
  transition: background 0.2s ease;
}
.feed-slide.expanded .feed-more-hint { background: var(--accent); }
/* Collapsed to 0 height via the grid-template-rows trick (an animatable
   alternative to a guessed max-height) -- .feed-extra-inner supplies the
   overflow:hidden the trick needs to clip content during the transition. */
.feed-extra {
  width: 100%;
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.32s cubic-bezier(0.22, 0.9, 0.32, 1);
}
.feed-slide.expanded .feed-extra { grid-template-rows: 1fr; }
.feed-extra-inner {
  overflow: hidden;
  min-height: 0;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 8px;
}

.btn-primary {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  margin-top: 6px;
  background: var(--accent);
  color: var(--accent-ink);
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 14.5px;
  letter-spacing: 0.4px;
  padding: 12px 20px 11px;
  border-radius: 10px;
}
.btn-primary .btn-ico, .btn-primary svg { width: 13px; height: 13px; fill: var(--accent-ink); }

/* top-right action stack */
.feed-actions {
  position: absolute;
  top: calc(var(--sat) + 14px); right: 14px;
  z-index: 5;
  display: flex;
  flex-direction: column;
  gap: 14px;
  align-items: center;
}
.feed-action {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
}
.feed-action > span {
  font-size: 8px;
  font-weight: 700;
  letter-spacing: 1.6px;
  text-transform: uppercase;
  color: #fff;
  text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
}
.icon-btn {
  width: 34px; height: 34px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: none;
  border: 0;
}
.icon-btn .ico { width: 20px; height: 20px; stroke: var(--accent); stroke-width: 2; fill: none; }
.icon-btn .ico-check { display: none; }
.icon-btn.in .ico-plus { display: none; }
.icon-btn.in .ico-check { display: block; }

/* feed dots */
.dots-feed {
  position: absolute;
  left: 0; right: 0;
  bottom: 14px;
  z-index: 5;
}

/* ---------------- search ---------------- */
#view-search { display: flex; flex-direction: column; }
.search-top { flex: 0 0 auto; padding: calc(var(--topbar-h) + var(--sat) + 14px) 16px 0; }
.search-pill {
  display: flex;
  align-items: center;
  gap: 10px;
  background: var(--bg2);
  border: 1px solid var(--line);
  border-radius: 24px;
  padding: 12px 16px;
}
.search-pill svg { width: 17px; height: 17px; stroke: var(--dim); stroke-width: 2; fill: none; flex: 0 0 auto; }
.search-pill input {
  flex: 1;
  min-width: 0;
  background: none;
  border: 0;
  outline: 0;
  color: var(--fg);
  font-family: var(--font-body);
  font-size: 14.5px;
}
.search-pill input::placeholder { color: var(--dim); }
.search-pill input::-webkit-search-cancel-button { -webkit-appearance: none; }

/* foldable filter rows */
.filters { margin-top: 10px; }
.filter-row {
  width: 100%;
  display: flex;
  align-items: center;
  gap: 10px;
  background: var(--bg2);
  border: 1px solid var(--line);
  border-radius: 12px;
  padding: 12px 14px;
  margin-top: 8px;
}
.filter-name {
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 1.8px;
  text-transform: uppercase;
  color: var(--fg2);
}
.filter-val {
  margin-left: auto;
  font-size: 13px;
  font-weight: 700;
  color: var(--fg2);
}
.filter-val.set { color: var(--accent); }
.filter-row .chev {
  width: 16px; height: 16px;
  stroke: var(--dim);
  stroke-width: 2;
  fill: none;
  flex: 0 0 auto;
  transition: transform 0.2s ease;
}
.filter-row[aria-expanded="true"] .chev { transform: rotate(180deg); }
.filter-panel { padding: 10px 2px 4px; }
.filter-panel[hidden] { display: none; }

.chips-wrap {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
.chip {
  flex: 0 0 auto;
  border: 1px solid var(--line);
  border-radius: 18px;
  padding: 7px 14px 6px;
  font-size: 12.5px;
  font-weight: 600;
  color: var(--fg2);
  white-space: nowrap;
}
.chip.active { border-color: var(--accent); color: var(--accent); }

.search-label {
  padding: 12px 0 6px;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 2.2px;
  text-transform: uppercase;
  color: var(--dim);
}

.search-list {
  flex: 1;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
  padding: 4px 16px 20px;
}
.search-list::-webkit-scrollbar { display: none; }

/* ---------------- list rows (search + watchlist) ---------------- */
.row {
  display: flex;
  align-items: center;
  gap: 13px;
  padding: 11px 0;
}
.row-thumb {
  flex: 0 0 auto;
  width: 50px; height: 74px;
  border-radius: 6px;
  overflow: hidden;
  background: var(--card);
}
.row-thumb { transition: transform 0.2s ease; }
.row:hover .row-thumb { transform: scale(1.08); }
.row-thumb img { width: 100%; height: 100%; object-fit: cover; }
.row-main { flex: 1; min-width: 0; }
.row-title {
  font-size: 15px;
  font-weight: 700;
  color: var(--fg);
  line-height: 1.25;
  display: flex;
  align-items: center;
  gap: 7px;
  overflow: hidden;
}
.row-title .t {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.row-tagline {
  margin-top: 3px;
  font-style: italic;
  font-size: 12.5px;
  color: var(--fg2);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.row-tagline[hidden] { display: none; }

.search-list .row { position: relative; }
.search-list .row-meta { margin-top: 11px; }
.row-meta {
  margin-top: 3px;
  font-size: 12px;
  color: var(--fg2);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.row .stars { font-size: 11px; margin-top: 4px; display: inline-block; }
.row-side { flex: 0 0 auto; }
.row-side .wl-btn { position: static; }

.list-empty {
  padding: 48px 24px;
  text-align: center;
  color: var(--fg2);
  font-size: 14px;
  line-height: 1.5;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}

/* ---------------- watchlist ----------------
   Unwatched and Watched are both always on screen — a vertical list
   each, stacked on a phone and side by side once there is room for two
   columns — rather than a toggle switching between them. Showing both
   at once is what makes marking a title watched read as a MOVE from one
   list to the other instead of a disappearance; see flipWatchlistRow in
   app.js for the animation that carries a row across the join. */
#view-watchlist { display: flex; flex-direction: column; }
.wl-head { flex: 0 0 auto; padding: calc(var(--topbar-h) + var(--sat) + 14px) 16px 4px; }
.wl-head h1 {
  font-size: 22px;
  font-weight: 700;
  letter-spacing: 0.2px;
}

.wl-columns {
  flex: 1;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
  padding-bottom: 24px;
}
.wl-columns::-webkit-scrollbar { display: none; }
.wl-col + .wl-col { border-top: 1px solid var(--line); margin-top: 10px; padding-top: 2px; }
.wl-col-head {
  margin: 14px 16px 2px;
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 1.8px;
  text-transform: uppercase;
  color: var(--dim);
}
.wl-col-count {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0;
  text-transform: none;
  color: var(--fg2);
  background: var(--bg2);
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 1px 7px 0;
}

.wl-list { padding: 6px 16px 0; }
.wl-list .row { align-items: flex-start; }
.wl-list .row-thumb,
.search-list .row-thumb { width: 60px; height: 88px; border-radius: 8px; }
.search-list .row { align-items: flex-start; }
/* The row that just crossed from the other column — see
   flipWatchlistRow. A brief gold wash, the same colour language as the
   bookmark toast, so "this moved" reads as a deliberate, good thing. */
.wl-row-landing { position: relative; z-index: 2; }
.wl-row-landing::after {
  content: "";
  position: absolute;
  inset: -4px -10px;
  border-radius: 12px;
  background: rgba(201, 132, 58, 0.16);
  animation: bb-wl-row-glow 2.7s ease-out;
  pointer-events: none;
}
@keyframes bb-wl-row-glow {
  0% { opacity: 1; }
  100% { opacity: 0; }
}


.row-acts {
  display: flex;
  gap: 8px;
  margin-top: 9px;
}
.pill-act {
  border: 1px solid var(--accent);
  border-radius: 16px;
  padding: 6px 13px 5px;
  font-size: 11.5px;
  font-weight: 700;
  color: var(--accent);
  white-space: nowrap;
}
.pill-act.quiet { border-color: var(--line); color: var(--fg2); }

/* ---------------- surprise me ---------------- */
.sur-scroll {
  height: 100%;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
  padding: 26px 20px 30px;
}
.sur-scroll::-webkit-scrollbar { display: none; }

.sur-head {
  font-size: 23px;
  font-weight: 700;
  line-height: 1.25;
  text-align: center;
  color: var(--fg);
}
.die {
  position: relative;
  display: block;
  width: 148px; height: 148px;
  margin: 30px auto 0;
  background: var(--accent);
  border-radius: 30px;
  box-shadow: 0 0 70px rgba(201, 132, 58, 0.35);
}
.die-dot {
  position: absolute;
  width: 19px; height: 19px;
  border-radius: 50%;
  background: #171208;
  transform: translate(-50%, -50%);
}
.d-tl { left: 29%; top: 29%; }
.d-tr { left: 71%; top: 29%; }
.d-c  { left: 50%; top: 50%; }
.d-bl { left: 29%; top: 71%; }
.d-br { left: 71%; top: 71%; }
@keyframes bb-roll {
  0%   { transform: rotate(0deg) scale(1); }
  30%  { transform: rotate(150deg) scale(0.84); }
  65%  { transform: rotate(290deg) scale(1.08); }
  100% { transform: rotate(360deg) scale(1); }
}
.die.rolling { animation: bb-roll 0.65s ease; }

.sur-cta { text-align: center; margin-top: 24px; }
.btn-pill { border-radius: 24px; padding: 13px 30px 12px; }

.sur-chips { justify-content: center; margin-top: 22px; }
.sur-label {
  margin-top: 26px;
  text-align: center;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 2.2px;
  text-transform: uppercase;
  color: var(--dim);
}
.sur-label + .sur-chips { margin-top: 12px; }
.chip.rating-off { opacity: 0.45; }
.chip.rating-on { border-color: var(--accent); color: var(--accent); }
.sur-explainer {
  margin: 26px auto 0;
  max-width: 300px;
  text-align: center;
  font-size: 12.5px;
  line-height: 1.55;
  color: var(--dim);
}

/* full-page surprise result (same treatment as the Latest feed) */
#view-surprise { perspective: 1400px; }
#surprise-result {
  position: absolute;
  inset: 0;
  isolation: isolate;
  overflow: hidden;
  /* "Surprise me again" flips the whole page like a card: ease-out on
     the way back in (the default transition here); .flip-out overrides
     with ease-in so the two halves read as one continuous turn. The
     swap happens edge-on at 90°, where the content is invisible. */
  transition: transform 0.55s cubic-bezier(0.2, 0.7, 0.35, 1);
  will-change: transform;
}
#surprise-result.flip-out {
  transform: rotateY(-90deg);
  transition-timing-function: cubic-bezier(0.55, 0.05, 0.85, 0.5);
}
#surprise-result.flip-in { transform: rotateY(90deg); }
#surprise-result.flip-snap { transition: none; }
#surprise-result[hidden] { display: none; }
.sur-bg {
  position: absolute;
  left: 0; top: 0;
  width: 100%; height: auto;
  z-index: -1;
}
/* The waiting half of the double buffer: invisible, but NOT display:none —
   a hidden element is never decoded, which is exactly the cost we are
   trying to avoid. Transparent keeps it decoded and instantly swappable.
   Its animation is stopped: a paused layer is composited once and left
   alone, whereas animating something nobody can see is pure waste and
   was enough to make continuous swiping stutter. */
.sur-standby { opacity: 0; animation: none !important; }
#surprise-result::after {
  content: "";
  position: absolute; inset: 0;
  /* Same as the feed's, and for the same reasons. */
  background: none;
  z-index: 0;
}
.sur-back {
  position: absolute;
  top: 14px; left: 14px;
  z-index: 2;
  display: inline-flex;
  align-items: center;
  gap: 7px;
  font-size: 13px;
  font-weight: 700;
  color: #fff;
  text-shadow: 0 1px 6px rgba(0, 0, 0, 0.7);
  padding: 6px 4px;
}
.sur-back svg { width: 16px; height: 16px; stroke: var(--accent); stroke-width: 2.4; fill: none; filter: drop-shadow(0 1px 3px rgba(0,0,0,.6)); }
.sur-actions { z-index: 3; }
/* Inside the top-right action stack the button is laid out by the stack
   itself; size/shadow/etc all come from the site-wide .wl-btn rules. */
.sur-actions .wl-btn { position: static; }

.sur-body {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  z-index: 1;
  /* Top padding matches .feed-text: it is the distance the shading ramps
     up across before the first line of text. The shading itself comes
     from the shared .feed-text/.sur-body rule — deliberately NOT
     repeated here, since two copies is how the two pages drifted apart
     in the first place. */
  /* 11px, not 22. Under the three buttons that much space read as a gap
     rather than as the end of the page — and it was twice the 12px above
     them. (Re-applied from a change made in a parallel chat whose files
     were lost; see the readme.) */
  padding: 64px 18px 11px;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 9px;
}
/* The verdict text starts tucked away (same as the Home feed) and grows
   open on swipe-up / tap — reusing the feed's .feed-extra collapse. */
#surprise-result.expanded .feed-extra { grid-template-rows: 1fr; }
/* Opened by the app rather than by a thumb (after picking a card): three
   times as slow as the swipe-up, because nobody asked for it and it has
   to be seen to happen. */
#surprise-result.opening .feed-extra { transition-duration: 0.95s; }
#surprise-result.expanded .feed-more-hint { background: var(--accent); }
.sur-verdict {
  font-size: 13.5px;
  line-height: 1.5;
  color: rgba(255, 255, 255, 0.82);
  display: -webkit-box;
  -webkit-line-clamp: 4;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
/* An empty line between the review text and the button. .btn-primary's
   own 6px plus the block's 9px flex gap put it right up against the
   last line of the verdict, so it read as part of the paragraph rather
   than as the thing you do after reading it. 6 + 9 + 20.25 (one line of
   .sur-verdict) ≈ one blank line. */
#sur-read { margin-top: 26px; }
.sur-btns {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 8px;
  width: 100%;
}
.sur-btns .btn-primary { flex: 1; justify-content: center; margin-top: 0; }
/* Three buttons share one row on Surprise Me — tighter type so
   "Surprise settings" and "Surprise me again" fit on phone widths. */
.sur-btns .btn-primary,
.sur-btns .btn-outline {
  font-size: 11.5px;
  letter-spacing: 0.8px;
  padding: 11px 8px 10px;
  white-space: nowrap;
}
.sur-settings-btn { position: relative; }
.sur-settings-btn span[hidden] { display: none; }
.sur-settings-btn span {
  position: absolute;
  top: -5px; right: -5px;
  min-width: 16px; height: 16px;
  border-radius: 8px;
  background: var(--accent);
  color: var(--accent-ink);
  font-size: 10px;
  font-weight: 700;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0 4px;
}
.btn-outline {
  flex: 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  border: 1px solid rgba(255, 255, 255, 0.35);
  background: rgba(10, 9, 7, 0.45);
  color: #fff;
  /* Sentence case, and the words carry themselves. Set in caps with
     1.6px of tracking they read as signage rather than as things to
     press, and the icons that came with them were decoration on a label
     that already said what it did. */
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 14.5px;
  letter-spacing: 0.4px;
  padding: 12px 16px 11px;
  border-radius: 10px;
}

/* ---------------- about ---------------- */
.about-scroll {
  height: 100%;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
  padding: calc(var(--topbar-h) + var(--sat) + 22px) 20px 34px;
}
.about-scroll::-webkit-scrollbar { display: none; }
.about-label {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 2.4px;
  text-transform: uppercase;
  color: var(--accent);
}
.about-head {
  margin-top: 14px;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 38px;
  line-height: 1.02;
  color: var(--fg);
  text-transform: none;
}
.about-head .am { color: var(--accent); display: block; }
.about-body {
  margin-top: 18px;
  border-top: 1px solid var(--line);
  padding-top: 6px;
}
.about-body p {
  margin-top: 14px;
  font-size: 14.5px;
  line-height: 1.65;
  color: var(--fg2);
}
.about-body p strong, .about-body p b { color: var(--fg); }

/* ---------------- About → Our Reviewers ---------------- */
.about-sec-head {
  margin: 26px 0 4px;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 24px;
  color: var(--fg);
}
#about-reviewers[hidden] { display: none; }
.reviewer {
  display: flex;
  gap: 14px;
  align-items: flex-start;
  padding: 16px 0;
  border-top: 1px solid var(--line);
}
.reviewer-ava {
  flex: 0 0 auto;
  width: 64px; height: 64px;
  border-radius: 50%;
  overflow: hidden;
  background: var(--card);
  border: 1.5px solid var(--accent);
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 24px;
  color: var(--dim);
}
.reviewer-ava img { width: 100%; height: 100%; object-fit: cover; }
.reviewer-main { min-width: 0; }
.reviewer-name {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 19px;
  color: var(--fg);
  padding: 0;
  text-align: left;
  text-decoration: underline;
  text-underline-offset: 3px;
  text-decoration-color: rgba(201, 132, 58, 0.55);
}
.reviewer-count {
  margin-top: 2px;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 1.6px;
  text-transform: uppercase;
  color: var(--accent);
}
.reviewer-bio {
  margin-top: 7px;
  font-size: 14px;
  line-height: 1.6;
  color: var(--fg2);
}

/* ---------------- boot ---------------- */
#view-boot { display: flex; align-items: center; justify-content: center; }
#view-boot[hidden] { display: none; }
.soon-inner {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
  padding: 32px;
  text-align: center;
}
.soon-icon svg { width: 44px; height: 44px; stroke: var(--accent); stroke-width: 1.6; fill: none; }
.soon-title {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 34px;
  text-transform: uppercase;
  line-height: 0.95;
}
.soon-text { font-size: 14px; color: var(--fg2); }

/* Held back until the display face has actually arrived. The font is
   requested with display=swap, so the browser paints the first fallback
   it has — Arial Narrow — and then swaps Oswald in underneath the
   reader: on the boot screen, where this word is the only thing on
   screen, that swap is the most conspicuous thing the app does. A
   fraction of a second with nothing there reads as loading, which is
   what the screen is for. Revealed by bootLogo() in app.js, with a
   timeout so a font that never loads cannot leave it blank. */
.boot-logo { visibility: hidden; }
.boot-logo.ready { visibility: visible; }
.boot-logo {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 30px;
  padding-bottom: 4px;
  background-image: linear-gradient(90deg, var(--accent), rgba(201, 132, 58, 0));
  background-repeat: no-repeat;
  background-size: 100% 2px;
  background-position: 0 100%;
}
.boot-spinner {
  width: 26px; height: 26px;
  border-radius: 50%;
  border: 2.5px solid var(--line);
  border-top-color: var(--accent);
  animation: spin 0.8s linear infinite;
}
.boot-spinner[hidden] { display: none; }
@keyframes spin { to { transform: rotate(360deg); } }

/* ---------------- tab bar ---------------- */
.tabbar {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: var(--tabbar-total);
  display: flex;
  background: rgba(14, 13, 11, 0.97);
  border-top: 1px solid var(--line);
  padding-bottom: var(--sab-keep);
  z-index: 20;
}
/* Bottom-weighted rather than centred: the icons sit at the foot of the
   bar, above the strip reserved for the home indicator (see --sab-keep).
   The bar's height grows by the same amount, so the icons keep their own
   62px of room rather than being squeezed by the padding. */
.tab {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-end;
  gap: 4px;
  /* No padding of its own — the bar already reserves the strip below,
     and adding it here as well would count it twice. */
  color: var(--dim);
}
.tab svg { width: 21px; height: 21px; stroke: currentColor; stroke-width: 1.8; fill: none; }
.tab svg .dot { fill: currentColor; stroke: none; }
.tab span {
  font-size: 9.5px;
  font-weight: 600;
  letter-spacing: 0.6px;
}
.tab.active { color: var(--accent); }

/* ---------------- homepage extras ---------------- */

/* ---------------- review overlay (the "end page") ---------------- */
.overlay {
  position: absolute;
  inset: 0;
  /* Stops above the tab bar (matching .view) instead of covering the
     whole screen, so the tab bar stays visible and usable on the movie
     and person pages, same as everywhere else in the app. z-index is
     unchanged (still above .app-topbar) so the review page's own
     back/share/bookmark header still takes over from the wordmark, same
     as before -- only the bottom edge moved. */
  bottom: var(--tabbar-total);
  z-index: 30;
  background: var(--bg);
  display: flex;
  flex-direction: column;
}
.overlay[hidden] { display: none !important; }


.ov-scroll {
  flex: 1;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
}
.ov-scroll::-webkit-scrollbar { display: none; }
/* Installed to the home screen, the app owns the whole screen — so
   without this the hero ran up behind the clock and the battery. It
   keeps its own height and is pushed down clear of them instead: the
   status bar then sits over the page background, the way it does in a
   native app, rather than over the picture. */
.ov-hero {
  position: relative;
  margin-top: var(--sat);
  height: 235px;
  background: var(--card);
  /* Required now the hero breathes: without it the growing image would
     spill out over the review text below. */
  overflow: hidden;
}
.ov-img { width: 100%; height: 100%; object-fit: cover; }
/* Nothing across the top: the back button and the bookmark carry their
   own drop-shadows, which is what makes them legible over artwork —
   dimming the whole top of the picture to protect two icons was paying
   for them twice.
   The lower half does two jobs at once: it is the ground the title sits
   on, and it is how the picture ends. It finishes on the page's own
   colour exactly, so the hero dissolves into the page rather than
   stopping at an edge — at 0.96 there was still a seam. */
.ov-hero::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(180deg,
    rgba(14, 13, 11, 0) 40%,
    rgba(14, 13, 11, 0.28) 62%,
    rgba(14, 13, 11, 0.72) 82%,
    rgba(14, 13, 11, 0.94) 94%,
    var(--bg) 100%);
}
.ov-back {
  position: absolute;
  top: calc(var(--sat) + 14px); left: 14px;
  z-index: 2;
  width: 37px; height: 37px;
  border-radius: 50%;
  border: 1.5px solid var(--accent);
  background: rgba(10, 9, 7, 0.55);
  display: flex;
  align-items: center;
  justify-content: center;
}
.ov-back svg { width: 18px; height: 18px; stroke: var(--accent); stroke-width: 2.2; fill: none; }
/* Both sit INSIDE the hero, which already starts below the status bar,
   so they must not offset for it a second time — 14px from the top of
   the picture, exactly as in the browser. (.ov-back's own rule carries
   the inset because the person and list pages use it against the bare
   top of the screen.) */
.ov-hero .wl-btn,
.ov-hero .ov-back { top: 14px; }
.ov-body {
  position: relative;
  z-index: 1;
  margin-top: -44px;
  padding: 0 18px 12px;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 9px;
}
.ov-title {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 37px;
  line-height: 1.02;
  color: var(--fg);
}
.ov-meta { font-size: 12.5px; color: var(--fg2); }
.ov-meta[hidden] { display: none; }
.ov-tagline { font-style: italic; font-size: 16px; color: var(--fg2); margin-bottom: 13px; }
/* POSITIVE, not negative: .ov-title runs at line-height 1.02 so glyphs
   overflow their line box downward, and a negative margin dropped the
   tagline onto the title's descenders. */
.ov-body .ov-title { margin-bottom: 2px; }
/* A little air between the details line and the rating reels. */
.ov-body .ov-meta { margin-bottom: 3px; }
.ov-body #ov-stars { margin-bottom: 12px; }
/* "New review" badge on feed posters (reviews published since the
   visitor's previous visit). */
.feed-new {
  align-self: flex-start;
  background: var(--star-on);
  color: var(--accent-ink);
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 1.8px;
  text-transform: uppercase;
  padding: 4px 8px 3px;
  border-radius: 4px;
  margin-bottom: 2px;
}

/* Reader reactions ("Your take") under the review */
.ov-react { width: 100%; }
.react-chips {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 8px;
  margin-top: 10px;
}
.react-chip {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--bg2);
  color: var(--fg);
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 12px;
  letter-spacing: 0.4px;
  text-transform: none;
  justify-content: center;
  padding: 10px 14px 9px;
  transition: background 0.15s ease, border-color 0.15s ease;
}
.react-chip span { color: var(--dim); font-size: 11.5px; min-width: 0; }
.react-chip span:empty { display: none; }
.react-chip.on { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
.react-chip.on span { color: var(--accent-ink); }

/* Where to Watch (streaming providers) */
.ov-watch { width: 100%; }
.watch-groups { display: flex; flex-direction: column; gap: 9px; margin-top: 10px; }
.watch-group { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.watch-kind {
  flex: 0 0 52px;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 1.4px;
  text-transform: uppercase;
  color: var(--dim);
}
.watch-logo {
  width: 38px; height: 38px;
  border-radius: 9px;
  border: 1px solid var(--line);
  display: block;
}
.watch-prov {
  display: inline-flex;
  align-items: center;
  background: var(--bg2);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 5px 10px;
  font-size: 12px;
  color: var(--fg);
}
.watch-credit { margin-top: 9px; font-size: 10.5px; color: var(--dim); }

/* More Like This rail inside the review page */
/* The rail is the one block that never became a section: its heading sits
   in .ov-more, not .ov-side, so the band rule cannot reach it and it read
   as a continuation of Cast rather than as a new thing. A rule across the
   full width with real air above it does that job without giving a
   horizontal rail a header band it does not need.
   The 30px is on top of .ov-body's 9px flex gap, so ~39px above the line. */
.ov-more {
  /* Full bleed, like the bands directly above it — an inset rule under a
     run of edge-to-edge bands reads as a different kind of divider. Same
     -18px trick, with the padding put back so the heading and the rail
     still line up with the text. align-self because .ov-body is a flex
     column with align-items:flex-start; width:100% would fight the
     negative margins and overflow. */
  align-self: stretch;
  border-top: 1px solid var(--line);
  margin: 30px -18px 0;
  padding: 16px 18px 0;
}
.ov-more > .ov-label { margin-top: 0; }
.ov-more-rail { margin-top: 12px; padding: 0; }

/* Deal me three — pick from three face-down cards */
.sur-deal {
  position: absolute;
  inset: 0;
  z-index: 27;
  /* Dark enough to sit the cards on, light enough that the film behind
     is still there — it is the one you would otherwise have been given,
     and the hand is being dealt over it, not instead of it. */
  background: rgba(8, 7, 5, 0.62);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  display: flex;
  align-items: center;
  justify-content: center;
  /* A shade heavier at the top than the bottom, so the hand sits just
     below centre once all three are turned over and the arrangement is
     at its full height. */
  padding: 54px 0 44px;
  /* The whole overlay — cards, dark wash and blur together — fades at the
     end of a pick (.fading). Declared here rather than on a state class
     so the fade is the same however the overlay leaves. */
  transition: opacity 0.26s ease;
}
.sur-deal[hidden] { display: none; }
.sur-deal-inner {
  width: 100%;
  padding: 0 16px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}

/* The hand. The cards are placed rather than flowed: face down they sit
   fanned and overlapping along the bottom, and each one turned over
   moves up into a place of its own — first to the top, then bottom left,
   then bottom right — so by the end all three posters are fully in view.
   Every position is a transform set from JS, where the width of the
   thing is actually known. */
.deal-cards {
  position: relative;
  width: 100%;
  perspective: 1200px;
}
.deal-card {
  position: absolute;
  left: 0; top: 0;
  cursor: pointer;
  /* Dealt: each card starts stacked and small (--from) and travels to
     its place (--rest, which is replaced by its own slot once turned).
     The fallback keeps it out of sight if the JS never runs. */
  opacity: 0;
  transform: var(--from, scale(0.4));
  transition: transform 0.62s cubic-bezier(0.2, 0.8, 0.25, 1), opacity 0.3s ease;
}
.deal-card.dealt { opacity: 1; transform: var(--rest); }
/* Turned over, a card leaves the fan for its own place. Which place is
   set from JS per card and fixed by where it already lies, so nothing
   moves sideways past anything else. */
.deal-card.dealt.flipped { transform: var(--place); }

/* The back of the chosen card, once it has taken the textless poster:
   no lattice, no perforations, and the picture laid out as the page
   lays it out — full width, anchored to the top. */
.deal-back.deal-art {
  display: block;
  background: none;
}
.deal-back.deal-art::after { content: none; }
/* position:absolute (matching .feed-img/.sur-bg) so applyFaceFraming's
   top/bottom anchor actually has something to move — those are no-ops
   on a plain in-flow block, which is what let this drift from the
   surprise-result page's own crop of the same picture. */
.deal-back.deal-art img { position: absolute; left: 0; top: 0; width: 100%; height: auto; min-height: 100%; display: block; }
/* Three more half-turns, slowing to a stop — 720deg is 0deg, which is
   the back of the card, now carrying the poster the page is made of.
   The card keeps its .flipped class throughout, so this rule has to be
   the more specific of the two. The easing is nearly all deceleration:
   the tumble is what says something is happening, the slowing is what
   makes it land rather than simply stop. */
.deal-card.flipped.turning .deal-inner {
  transform: rotateY(720deg);
  transition: transform 2.4s cubic-bezier(0.12, 0.72, 0.2, 1);
}

/* ---- picking a card ----
   The chosen card grows until it covers the screen, and what it grows
   into is the page it is about to become. Everything that only makes
   sense at card size — the title block, the rounded corners, the shadow,
   the other two cards — goes on the way. */
.deal-card.picked {
  z-index: 9;
  /* Unhurried: this is the card becoming the page, and the eye needs
     long enough to follow one into the other. */
  transition: transform 1.1s cubic-bezier(0.32, 0, 0.2, 1);
}
/* Growing to full screen, everything that only makes sense at card size
   goes: the rounded corners, the hairline and the lift. They are all
   box-shadow now (see .deal-face), so one property carries them out. */
.deal-card.picked .deal-face {
  border-radius: 0;
  box-shadow: none;
  transition: border-radius 0.5s ease, box-shadow 0.4s ease;
}
.deal-card.picked .deal-title { opacity: 0; transition: opacity 0.25s ease; }
.deal-cards.picking .deal-card:not(.picked) {
  opacity: 0;
  transition: opacity 0.28s ease;
}
/* The backdrop stays blurred for the whole pick. It used to clear itself
   as the card grew, on the theory that the screen behind should be ready
   by the time the card arrived — but the film behind snapping into focus
   while the poster was still growing was two things happening at once,
   and the eye went to the wrong one. It now simply fades out with the
   rest of the overlay at the end, by which point the page beneath is the
   same picture and carries its own blurred fill anyway. */
.sur-deal.fading { opacity: 0; }
.deal-inner {
  position: relative;
  width: 100%;
  height: 100%;
  transform-style: preserve-3d;
  /* Deliberately slow: this is the moment the card is being turned over,
     and hurried it does not register as a turn at all. */
  transition: transform 1.1s cubic-bezier(0.3, 0.75, 0.25, 1);
}
.deal-card.flipped .deal-inner { transform: rotateY(180deg); }
.deal-face {
  position: absolute;
  inset: 0;
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
  border-radius: 12px;
  overflow: hidden;
  /* The hairline is an INSET SHADOW, not a border. A border is part of
     the box, so with box-sizing: border-box it shrank the content by 1px
     on each side — and the poster inside is laid out width:100% with its
     own aspect, so a 2:3 picture in a box 2px narrower than it is tall
     came up about 2px short and left a transparent strip along the
     bottom. Invisible on a dark poster, obvious on a bright one, and
     still there behind the turn and the target picture.
     An inset shadow is painted, not laid out: the content box is the
     full card, the picture fills it, and the hairline still reads the
     same. It also removes the reason the grow had to animate the border
     away to reach the screen edges.
     A card lifted off the table rather than an edge drawn round it: on a
     backdrop this dark an outline reads as decoration, whereas a shadow
     says the card is a thing lying on top of something. */
  box-shadow:
    inset 0 0 0 1px var(--line),
    0 10px 26px rgba(0, 0, 0, 0.62),
    0 3px 8px rgba(0, 0, 0, 0.5);
}
/* The back of a playing card: a fine gold lattice over a dark ground,
   an inset frame, and a strip of film perforations down either edge —
   the deck this is dealt from is a film deck. */
.deal-back {
  background:
    repeating-linear-gradient(180deg, rgba(201, 132, 58, 0.34) 0 7px, rgba(201, 132, 58, 0) 7px 16px) left 3px top 0 / 4px 100% repeat-y,
    repeating-linear-gradient(180deg, rgba(201, 132, 58, 0.34) 0 7px, rgba(201, 132, 58, 0) 7px 16px) right 3px top 0 / 4px 100% repeat-y,
    repeating-linear-gradient(45deg, rgba(201, 132, 58, 0.075) 0 1px, rgba(201, 132, 58, 0) 1px 9px),
    repeating-linear-gradient(-45deg, rgba(201, 132, 58, 0.075) 0 1px, rgba(201, 132, 58, 0) 1px 9px),
    radial-gradient(circle at 50% 44%, rgba(201, 132, 58, 0.16) 0%, rgba(201, 132, 58, 0) 58%),
    linear-gradient(135deg, var(--card), var(--bg2));
  display: flex;
  align-items: center;
  justify-content: center;
}
.deal-back::after {
  content: "";
  position: absolute;
  inset: 9px;
  border: 1px solid rgba(201, 132, 58, 0.30);
  border-radius: 6px;
  pointer-events: none;
}
/* The reel on the back of a dealt card. It only ever turns — no
   unfolding here; the card itself is the thing that opens. The gold rim
   is the same one the logo's pill carries: without it the reel floats on
   the card with nothing to finish its edge. */
.deal-reel {
  display: block;
  padding: 5px;
  border: 1.5px solid transparent;
  border-radius: 50%;
  background:
    linear-gradient(150deg, rgba(20, 17, 12, 0.92), rgba(10, 9, 7, 0.92)) padding-box,
    linear-gradient(135deg, #f7dfa4, #c9843a 55%, #8a5722) border-box;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.55);
  animation: bb-reel-spin 6s linear infinite;
  /* The reel is animating, so the browser gives it its own compositing
     layer — and a layer of its own escapes the card face's backface
     culling. Left alone it kept being drawn after the card had turned,
     mirrored (hence apparently spinning backwards) on top of the poster.
     backface-visibility is the correct instruction and is honoured by
     some engines; the visibility switch below is what actually
     guarantees it, and it is timed to the exact half-way point of the
     turn — the moment the card is edge-on and nothing can be seen of
     either face anyway. */
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
  transform-style: flat;
}
.deal-reel svg {
  display: block;
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
}
/* The poster keeps the gold hairline the card back wears, so a turned
   card still reads as a card rather than a picture lying loose. */
.deal-front {
  transform: rotateY(180deg);
  background: var(--card);
}

/* Laid out as the Surprise page lays its poster out — full width,
   anchored to the top, its own aspect — so the card is a window onto the
   top of that poster rather than a differently-cropped copy of it. */
/* min-height guards the last sub-pixel: the picture keeps its own
   aspect (height:auto) but can never end up shorter than the card. */
.deal-front img { width: 100%; height: auto; min-height: 100%; display: block; }
.deal-front .deal-title {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  padding: 26px 8px 8px;
  font-size: 11.5px;
  font-weight: 700;
  line-height: 1.25;
  color: #fff;
  background: linear-gradient(180deg, rgba(8, 7, 5, 0), rgba(8, 7, 5, 0.92));
}

/* ---------------- motion & micro-delight ----------------
   All decorative motion sits behind prefers-reduced-motion. */
/* Bookmark "pop" when a film is saved — brief functional feedback, so
   it runs regardless of the reduced-motion setting (iPhones have no
   vibration API in the browser; this is the save cue there). */
.wl-btn.pop .ico-bm, .icon-btn.pop .ico-bm {
  animation: bb-pop 0.4s cubic-bezier(0.2, 0.8, 0.3, 1.3);
}

@media (prefers-reduced-motion: no-preference) {
  /* Ken Burns pulse: zoom in on the detected eye line (transform-origin
     is set per-image from the eye-detect data; 50% 32% is the fallback
     for artwork without a measurement), then breathe back out — on the
     home feed AND the full-screen Surprise poster. */
  /* Zoomed OUT is the whole poster, edge to edge — the resting state is
     scale(1), never less, so no blur ever shows down the sides. Zooming
     IN from there grows past the frame, and which edges it grows away
     from is set per poster by applyFaceFraming: it pins the edge nearest
     the eyes on each axis, so the face travels toward the centre of the
     screen in BOTH directions as the zoom closes in. */
  .feed-slide .feed-img,
  .sur-bg {
    transform-origin: 50% 0%;
    animation: bb-poster-zoom 9s ease-in-out infinite alternate;
  }
  /* The blurred fill drifts on its own, which is what gives the screen
     its sense of movement now that the poster itself stays put. */
  .feed-slide .feed-blur,
  .sur-blur {
    animation: bb-blur-drift 9s ease-in-out infinite alternate;
  }
  /* The movie page's backdrop breathes too, on the same principle as the
     feed: the zoom closes in on the focus point rather than the middle of
     the picture. The origin comes from applyFaceFraming (app.js) — the
     detected eye line, or whatever was set by hand under Zoom focus →
     Website hero, since that is the same backdrop and the same stored
     point the website's own hero uses. A shallower zoom than the feed:
     this is a 235px strip with text right beneath it, so a big move would
     be restless rather than cinematic. */
  .ov-img {
    transform-origin: 50% 30%;
    animation: bb-hero-zoom 14s ease-in-out infinite alternate;
  }

  /* Stars fill in one by one when a review page opens. */
  #ov-stars .stars span {
    display: inline-block;
    opacity: 0;
    animation: bb-star-in 0.3s ease forwards;
  }
  #ov-stars .stars span:nth-child(1) { animation-delay: 0.05s; }
  #ov-stars .stars span:nth-child(2) { animation-delay: 0.13s; }
  #ov-stars .stars span:nth-child(3) { animation-delay: 0.21s; }
  #ov-stars .stars span:nth-child(4) { animation-delay: 0.29s; }
  #ov-stars .stars span:nth-child(5) { animation-delay: 0.37s; }
}
/* 9s at the same zoom RATE as the old 6s/11% version — so it travels
   further (16.5%) rather than moving more slowly. Which way the image
   travels as it grows is set by transform-origin, chosen per image from
   the measured eye line (see applyFaceFraming in app.js). */
/* scale(1) == the poster at exactly screen width (its own left and right
   edges), which is the zoomed-OUT end of the cycle. */
/* --kb-max is set per poster by applyFaceFraming: a face far off-centre
   needs a deeper zoom to reach the middle of the screen, so the depth
   adapts (capped) instead of every poster breathing the same amount. */
@keyframes bb-poster-zoom {
  from { transform: scale(1); }
  to { transform: scale(var(--kb-max, 1.165)); }
}
@keyframes bb-blur-drift {
  from { transform: scale(1.15); }
  to { transform: scale(1.32); }
}
/* Movie-page backdrop — same depth as the feed's posters (16.5%). */
@keyframes bb-hero-zoom {
  from { transform: scale(1); }
  to { transform: scale(1.165); }
}
@keyframes bb-pop {
  0% { transform: scale(1); }
  45% { transform: scale(1.8); }
  100% { transform: scale(1); }
}
@keyframes bb-star-in {
  from { opacity: 0; transform: scale(0.3); }
  to { opacity: 1; transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
  #surprise-result { transition: none; }
  .feed-arrow { animation: none; }
  .popup-card { animation: none; }
  .deal-inner { transition: none; }
}

.ov-byline {
  margin-top: 15px;
  font-style: italic;
  font-size: 13px;
  font-weight: 600;
  color: var(--fg2);
}
/* Tappable author name(s) inside the byline */
.byline-link {
  font: inherit;
  color: var(--accent);
  background: none;
  border: 0;
  padding: 0;
  text-decoration: underline;
  text-underline-offset: 3px;
  text-decoration-color: rgba(201, 132, 58, 0.5);
}
.ov-byline[hidden] { display: none; }
/* Byline sits directly above The Verdict — tighten the label's own gap
   when the byline is showing. */
.ov-byline + .ov-label { margin-top: 28px; }
/* The heading and the writing under it are not the same thing. */
.ov-label { margin-bottom: 10px; }
.ov-label {
  margin-top: 15px;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 2.2px;
  text-transform: uppercase;
  color: var(--accent);
}
.ov-review {
  font-size: 15px;
  line-height: 1.7;
  color: var(--fg);
}
.ov-review p { margin-bottom: 13px; }
.ov-review p:last-child { margin-bottom: 0; }
.ov-review .muted { color: var(--fg2); font-style: italic; }
.ov-cast {
  display: flex;
  gap: 14px;
  width: 100%;
  overflow-x: auto;
  padding: 6px 0 4px;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
}
.ov-cast::-webkit-scrollbar { display: none; }
.cast-card { flex: 0 0 78px; text-align: center; }
.cast-ava {
  width: 64px; height: 64px;
  margin: 0 auto;
  border-radius: 50%;
  overflow: hidden;
  background: var(--card);
  border: 1.5px solid var(--line);
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 22px;
  color: var(--dim);
}
.cast-ava img { width: 100%; height: 100%; object-fit: cover; }
.cast-name { margin-top: 6px; font-size: 11.5px; font-weight: 700; color: var(--fg); line-height: 1.25; }
.cast-role { margin-top: 2px; font-size: 10px; color: var(--dim); line-height: 1.25; }
.ov-end { height: 10px; }
.ov-footer {
  flex: 0 0 auto;
  display: flex;
  gap: 10px;
  padding: 12px 16px;
  border-top: 1px solid var(--line);
  background: var(--bg);
}
.ov-footer[hidden] { display: none; }
.ov-footer .btn-outline { flex: 1; justify-content: center; background: none; border-color: var(--line); color: var(--fg); }
.ov-footer .btn-outline[hidden] { display: none; }
.ov-footer-main, .ov-footer-side { display: contents; }
/* Only the ?layout=aligned experiment shows these. */
/* Sneak Peek stays wide-only: on a phone Trailer and Stills live in the
   fixed footer bar, so there is nothing in the flow for it to head. */
.ov-actions-side, .ov-share-label, .ov-share-inline, .ov-actions-label { display: none; }
/* Wide layout only — see the rule inside the media query. */
.strip-head { display: none; }

.share-sheet { position: absolute; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; padding: 20px; background: rgba(6,5,4,0.72); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); }
.share-sheet[hidden] { display: none; }
.share-card { width: 100%; max-width: 380px; background: var(--bg2); border: 1px solid var(--line); border-radius: 16px; padding: 18px 18px 14px; box-shadow: 0 18px 50px rgba(0,0,0,0.6); }
.share-head { font-size: 12px; font-weight: 700; letter-spacing: 1.8px; text-transform: uppercase; color: var(--dim); margin-bottom: 14px; }
/* SPECIFICITY, again. ".ov-share-inline { display: none }" above and
   ".share-targets { display: grid }" below are both a single class, and
   the grid one is written later — so this element was never actually
   hidden on a phone. It went unnoticed for as long as it was empty:
   an empty grid takes no space. The moment a window was dragged from
   desktop down to phone width it still held the tiles the wide layout
   had written, and they appeared under a heading that WAS correctly
   hidden — the "Share with no buttons" half of the resize report. Two
   classes here so the hiding wins; the aligned layout re-shows it with
   two classes of its own inside the media query. */
.share-targets.ov-share-inline { display: none; }
.share-targets { display: grid; grid-template-columns: repeat(auto-fit, minmax(96px, 1fr)); gap: 8px; margin-bottom: 14px; }
.share-target { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 12px 6px 10px; border: 1px solid var(--line); border-radius: 12px; font-size: 11.5px; font-weight: 700; color: var(--fg); background: none; transition: border-color 0.18s ease, background 0.18s ease; }
.share-target:hover { border-color: var(--accent); background: rgba(201,132,58,0.09); }
.share-target svg { width: 22px; height: 22px; fill: var(--accent); }
.share-close { width: 100%; text-align: center; justify-content: center; }

.st-arrow { position: absolute; top: 50%; transform: translateY(-50%); z-index: 6; width: 44px; height: 44px; border-radius: 50%; border: 1px solid var(--line); background: rgba(14,13,11,0.72); display: flex; align-items: center; justify-content: center; cursor: pointer; transition: background 0.2s ease, border-color 0.2s ease, opacity 0.2s ease; }
.st-arrow:hover { background: rgba(14,13,11,0.95); border-color: var(--accent); }
.st-arrow svg { width: 20px; height: 20px; stroke: var(--fg2); stroke-width: 2; fill: none; }
.st-arrow:hover svg { stroke: var(--accent); }
.st-arrow-l { left: 10px; }
.st-arrow-r { right: 10px; }
.st-arrow[hidden] { display: none; }

/* Year and genres, moved out of the centre column's meta line. */
.ov-facts { font-size: 12.5px; color: var(--fg2); }
.ov-facts-line + .ov-facts-line { margin-top: 3px; }
.ov-facts[hidden] { display: none; }

/* A name you can click should look like one — same language as the
   share tiles: the ring warms, the label brightens. */
.cast-card { cursor: pointer; }
.cast-card .cast-ava { transition: border-color 0.18s ease, box-shadow 0.18s ease; }
.cast-card:hover .cast-ava { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(201,132,58,0.18); }
.cast-card:hover .cast-name { color: var(--accent); }
.cast-name { transition: color 0.18s ease; }

/* The condensed display face is drawn for headlines and is hard work at
   button size; the body face is the one made for reading. */
.react-chip { font-family: var(--font-body); font-size: 12.5px; transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease; }
.ov-actions-side .btn-outline { transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease; }
.ov-actions-side .btn-outline { font-family: var(--font-body); letter-spacing: 0.2px; }

/* ---------------- general error card ----------------
   Built from the header badge's parts — reel, gold rim, pill unfolding
   sideways — so a failure reads as the app having a bad moment rather
   than the page having broken. */
.err-sheet { position: absolute; inset: 0; z-index: 70; display: flex; align-items: center; justify-content: center; padding: 22px; background: rgba(6,5,4,0.78); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
.err-sheet[hidden] { display: none; }
.err-card { display: flex; flex-direction: column; align-items: center; gap: 18px; max-width: 460px; }
.err-badge { position: relative; display: inline-flex; align-items: center; height: 60px; width: 60px; border: 1.5px solid transparent; border-radius: 30px; overflow: hidden; white-space: nowrap; background: linear-gradient(150deg, rgba(20,17,12,0.96), rgba(10,9,7,0.96)) padding-box, linear-gradient(135deg, #e8b06a, var(--accent) 55%, #8a5722) border-box; box-shadow: 0 2px 14px rgba(0,0,0,0.7), 0 0 22px rgba(201,132,58,0.26); transition: width 0.9s cubic-bezier(0.65,0,0.35,1); }
.err-badge .reel { flex: 0 0 auto; display: block; width: 50px; height: 50px; margin: 0 3.5px; }
.err-badge .reel svg { display: block; width: 100%; height: 100%; }
.err-badge .reel .reel-spin { display: block; animation: bb-reel-spin 4.5s linear infinite; }
/* flex:0 0 auto is load-bearing: the pill is a fixed 60px while shut and
   a flex child defaults to flex-shrink:1, so the message SQUEEZED to fit
   instead of overflowing — scrollWidth then reported 60px, the pill
   measured itself as already correct, and never unfolded. */
.err-block { flex: 0 0 auto; display: flex; flex-direction: column; align-items: flex-start; justify-content: center; margin-left: 13px; padding-right: 22px; opacity: 0; transform: translateX(-6px); transition: opacity 0.42s ease 0.4s, transform 0.42s ease 0.4s; }
.err-badge.open .err-block { opacity: 1; transform: translateX(0); }
/* Shut it must NOT wrap or the measurement returns a tall narrow column
   instead of the true unwrapped width. Open it must wrap, so a message
   too long for a phone reflows rather than being clipped. */
.err-badge.open { white-space: normal; height: auto; align-items: center; }
.err-badge { max-width: 100%; }
.err-badge.open .err-title { line-height: 1.15; }
.err-title { font-family: var(--font-display); font-weight: 700; font-size: 16px; letter-spacing: 0.3px; line-height: 1; color: var(--fg); }
.err-rule { width: 100%; height: 1px; margin: 5px 0; background: linear-gradient(90deg, var(--accent), rgba(201,132,58,0)); }
.err-sub { font-size: 12px; color: var(--fg2); line-height: 1.2; }
.err-sub:empty { display: none; }
.err-close { min-width: 128px; text-align: center; justify-content: center; }

/* ---------------- toast ---------------- */
/* Centred on screen (e.g. "Added to Watchlist") — pops in with a
   slight scale so it reads as confirmation, not a banner. */
.toast {
  /* Dead center (the old top:50%) sat behind a thumb mid-tap and behind
     the header on taller screens — moved up into clear space below the
     logo, where it's the only thing there. */
  position: absolute;
  left: 50%;
  top: 16%;
  transform: translate(-50%, -12px) scale(0.9);
  z-index: 40;
  background: rgba(20, 17, 12, 0.96);
  border: 1px solid var(--accent);
  color: var(--fg);
  font-size: 13px;
  font-weight: 600;
  padding: 12px 20px;
  border-radius: 22px;
  opacity: 0;
  pointer-events: none;
  /* The "golden aura" — a crisp gold rim plus a heavy, layered bloom
     around it, rather than just the flat 1px border it had before. */
  box-shadow: 0 0 0 2px rgba(201, 132, 58, 0.9), 0 0 20px 8px rgba(201, 132, 58, 0.6), 0 0 60px 22px rgba(201, 132, 58, 0.4), 0 14px 34px rgba(0, 0, 0, 0.6);
  transition: opacity 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease;
  white-space: nowrap;
}
.toast.show { opacity: 1; transform: translate(-50%, 0) scale(1); }

/* Same pill and gold aura as the global toast — carries the .toast
   class and overrides nothing but position. Declared after .toast so
   these transforms win. */
.row-toast {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, calc(-50% - 10px)) scale(0.9);
  z-index: 6;
}
.row-toast.show { transform: translate(-50%, -50%) scale(1); }

/* ---------------- list / collection overlay ---------------- */
.list-overlay { z-index: 31; }
.lv-hero {
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 9;
  overflow: hidden;
  background: var(--card);
}
.lv-hero img { width: 100%; height: 100%; object-fit: cover; display: block; }
.lv-hero::after {
  content: "";
  position: absolute; inset: 0;
  background: linear-gradient(180deg, rgba(14, 13, 11, 0.35) 0%, rgba(14, 13, 11, 0) 35%, rgba(14, 13, 11, 0) 55%, rgba(14, 13, 11, 1) 100%);
}
.lv-head {
  position: relative;
  padding: calc(var(--sat) + 58px) 18px 6px;
}
/* With a hero above, the head hugs the image instead of clearing the topbar. */
.lv-hero:not([hidden]) + .lv-head { padding-top: 4px; margin-top: -34px; z-index: 1; }
.lv-head .ov-back { position: fixed; top: calc(var(--sat) + 14px); left: 14px; z-index: 5; }
.lv-title {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 30px;
  line-height: 1.02;
  color: var(--fg);
}
.lv-note {
  margin-top: 6px;
  font-style: italic;
  font-size: 14px;
  color: var(--fg2);
}
.lv-sub {
  margin-top: 6px;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 1.8px;
  text-transform: uppercase;
  color: var(--dim);
}
.lv-list { padding-top: 10px; }

/* One-line editor's note under a curated collection's rail heading */
.rail-note {
  padding: 0 16px 10px;
  margin-top: -4px;
  font-style: italic;
  font-size: 13px;
  color: var(--fg2);
}

/* ---------------- person overlay (actor / director) ---------------- */
.person-overlay { z-index: 31; }
.pv-head {
  position: relative;
  padding: calc(var(--sat) + 58px) 18px 4px;
  text-align: center;
  background: linear-gradient(180deg, var(--card) 0%, var(--bg) 100%);
}
.pv-head .ov-back { position: absolute; top: calc(var(--sat) + 14px); left: 14px; }
.pv-ava {
  width: 98px; height: 98px;
  margin: 0 auto;
  border-radius: 50%;
  overflow: hidden;
  border: 2px solid var(--accent);
  background: var(--bg2);
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 36px;
  color: var(--dim);
}
.pv-ava img { width: 100%; height: 100%; object-fit: cover; }
.pv-name {
  margin-top: 13px;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 33px;
  line-height: 1.02;
  color: var(--fg);
}
.pv-meta {
  margin: 11px 0 16px;
  display: flex;
  gap: 10px;
  align-items: center;
  justify-content: center;
  flex-wrap: wrap;
}
.pv-role {
  border: 1px solid var(--accent);
  color: var(--accent);
  border-radius: 14px;
  padding: 4px 12px 3px;
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 1.8px;
  text-transform: uppercase;
}
.pv-count { font-size: 12px; color: var(--fg2); }
.pv-films-label { padding: 4px 18px 0; }
.pv-films { padding: 4px 18px 12px; }

/* ============================================================
   v3.5 — full-screen feed, bookmarks, brand, arrows, trailer,
   surprise-without-dice
   ============================================================ */

/* Bookmark icon: no circle backdrop anywhere on the site — just the
   icon, with a strong drop-shadow for readability over any artwork.
   Filled gold = already on the watchlist. Matches .icon-btn .ico-bm
   below (the homepage's version of the same icon). */
.wl-btn .ico-bm {
  width: 21px; height: 21px;
  stroke: var(--accent); stroke-width: 2;
  fill: none;
  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 1)) drop-shadow(0 2px 6px rgba(0, 0, 0, 0.95)) drop-shadow(0 4px 16px rgba(0, 0, 0, 0.85));
}
.wl-btn.in .ico-bm { fill: var(--accent); stroke: var(--accent); }

/* Homepage bookmark icon: no circle backdrop — just the icon, with the
   same strong drop-shadow treatment as the wordmark for readability over
   any artwork. Filled gold = already on the watchlist. */
.icon-btn .ico-bm {
  width: 21px; height: 21px;
  stroke: var(--accent); stroke-width: 2;
  fill: none;
  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 1)) drop-shadow(0 2px 6px rgba(0, 0, 0, 0.95)) drop-shadow(0 4px 16px rgba(0, 0, 0, 0.85));
}
.icon-btn.in .ico-bm { fill: var(--accent); }

/* Feed scroll hints — pure indicators, never catch taps */
.feed-arrow {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  z-index: 5;
  width: 30px; height: 44px;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
  opacity: 0.85;
  transition: opacity 0.25s ease;
  animation: bb-nudge 2.4s ease-in-out infinite;
}
.feed-arrow.off { opacity: 0; animation: none; }
.feed-arrow-l { left: 2px; }
.feed-arrow-r { right: 2px; }
.feed-arrow svg {
  width: 22px; height: 22px;
  stroke: rgba(255, 255, 255, 0.9);
  stroke-width: 2.4;
  fill: none;
  filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.8));
}
@keyframes bb-nudge {
  0%, 100% { transform: translateY(-50%) translateX(0); }
  50% { transform: translateY(-50%) translateX(3px); }
}
.feed-arrow-l { animation-name: bb-nudge-l; }
@keyframes bb-nudge-l {
  0%, 100% { transform: translateY(-50%) translateX(0); }
  50% { transform: translateY(-50%) translateX(-3px); }
}

/* Review overlay footer: Trailer / Movie Stills / Share. The share
   icon inside its button. */
#ov-share svg { width: 15px; height: 15px; stroke: #fff; stroke-width: 2; fill: none; }
.ov-footer .btn-outline {
  font-size: 11.5px;
  letter-spacing: 0.8px;
  padding: 11px 8px 10px;
  white-space: nowrap;
}

/* Stills viewer — fullscreen swipeable strip of film stills */
/* ---------------- popup windows (trailer + stills) ----------------
   Both open as a pop-up card floating over the review page — dimmed,
   blurred backdrop with the page still visible behind; tapping the
   backdrop (or the ×) closes them. */
.popup-overlay {
  position: fixed;
  inset: 0;
  z-index: 40;
  background: rgba(5, 4, 3, 0.62);
  backdrop-filter: blur(5px);
  -webkit-backdrop-filter: blur(5px);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px 14px;
}
.popup-overlay[hidden] { display: none; }
.popup-card {
  position: relative;
  width: 100%;
  max-width: 620px;
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 16px;
  overflow: hidden;
  box-shadow: 0 22px 70px rgba(0, 0, 0, 0.65), 0 4px 18px rgba(0, 0, 0, 0.5);
  animation: bb-popup-in 0.22s cubic-bezier(0.2, 0.8, 0.3, 1);
}
@keyframes bb-popup-in {
  from { transform: scale(0.94) translateY(10px); opacity: 0; }
  to { transform: scale(1) translateY(0); opacity: 1; }
}
.tr-close {
  position: absolute;
  top: 8px; right: 8px;
  z-index: 2;
  width: 36px; height: 36px;
  border-radius: 50%;
  border: 1.5px solid var(--accent);
  background: rgba(10, 9, 7, 0.75);
  display: flex;
  align-items: center;
  justify-content: center;
}
.tr-close svg { width: 16px; height: 16px; stroke: var(--accent); stroke-width: 2.2; fill: none; }

/* Trailer popup — the player fills the card at 16:9 */
.tr-frame {
  width: 100%;
  aspect-ratio: 16 / 9;
  background: #000;
}
.tr-frame iframe { width: 100%; height: 100%; display: block; border: 0; }

/* Stills popup — swipeable 16:9 strip + counter inside the card */
.st-track {
  width: 100%;
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
  background: #000;
}
.st-track::-webkit-scrollbar { display: none; }
.st-slide {
  flex: 0 0 100%;
  aspect-ratio: 16 / 9;
  scroll-snap-align: center;
  scroll-snap-stop: always;
  display: flex;
  align-items: center;
  justify-content: center;
}
.st-slide img {
  width: 100%;
  height: 100%;
  object-fit: contain;
}

/* Surprise Me — a movie is already on screen; filter lives top-left */
#surprise-result { position: absolute; inset: 0; isolation: isolate; overflow: hidden; }
/* The Surprise settings control now lives as a text button in the
   bottom row (see .sur-settings-btn with .sur-btns above). */
.sur-sheet {
  position: absolute;
  inset: 0;
  z-index: 26;
  background: rgba(8, 7, 5, 0.9);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  overflow-y: auto;
}
.sur-sheet[hidden] { display: none; }
.sur-sheet-inner { padding: 64px 18px 40px; }
.sur-sheet .sur-label:first-child { margin-top: 0; }
.sur-sheet-done { display: block; margin: 26px auto 0; }

/* ============ SERIES BADGE ============
   Only series carry a badge. Films are the overwhelming majority, so
   marking every one of them would be noise; marking the exception is
   information. Deliberately small and low-contrast — it labels the
   artwork, it doesn't compete with it. */
.type-badge {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font-family: "Oswald", sans-serif;
  font-size: 9px;
  font-weight: 600;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  line-height: 1;
  padding: 3px 6px 3px 5px;
  border-radius: 4px;
  color: #0e0d0b;
  background: var(--accent);
  white-space: nowrap;
}
.type-badge svg {
  width: 11px;
  height: 11px;
  stroke: currentColor;
  stroke-width: 1.8;
  fill: none;
}
.type-badge svg rect { fill: none; }

/* On artwork the badge is an overlay, so it floats top-left and gets a
   dark backing rather than the solid gold block used inline. */
.poster-thumb .type-badge,
.still-card .type-badge {
  position: absolute;
  top: 8px;
  left: 8px;
  z-index: 3;
  color: var(--accent);
  background: rgba(14, 13, 11, 0.72);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
}
/* In the feed the badge lives inside the text column, so it behaves like
   the "New review" tag above it rather than an overlay. */
.feed-text .type-badge { align-self: flex-start; margin-bottom: 2px; }

/* In list rows there is no artwork to overlay, so it reads as a tag
   beside the title. .row-title is a flex row, hence the shrink guard. */
.row-title .type-badge { margin-left: 8px; flex: 0 0 auto; }


/* ============ PULL TO REFRESH ============
   A single arrow that rides down with the drag and spins once it fires.
   Above every overlay, since the gesture works on those too. */
.ptr {
  position: absolute;
  top: calc(var(--sat) + 6px);
  left: 50%;
  transform: translate(-50%, 0);
  z-index: 60;
  width: 34px; height: 34px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(14, 13, 11, 0.82);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  border: 1px solid var(--line);
  opacity: 0;
  pointer-events: none;
}
.ptr svg { width: 19px; height: 19px; stroke: var(--dim); stroke-width: 2; fill: none; }
/* Pulled far enough to fire: the arrow turns gold and flips to point back up. */
.ptr.ready svg { stroke: var(--accent); transform: rotate(180deg); }
.ptr svg { transition: transform 0.18s ease, stroke 0.18s ease; }
.ptr.snap { transition: transform 0.24s ease, opacity 0.24s ease; }
.ptr.spin svg { animation: bb-ptr-spin 0.7s linear infinite; }
@keyframes bb-ptr-spin { to { transform: rotate(540deg); } }


/* ---------------- landscape ----------------
   A website cannot lock a phone's orientation. The manifest's
   "orientation": "portrait" is honoured by an installed app on Android
   and ignored by iOS, and screen.orientation.lock() needs fullscreen and
   does not exist in Safari at all. Rotating the whole app back with a
   CSS transform is the usual trick, but this app is built on a
   horizontal scroll-snap carousel, pointer maths and safe-area insets,
   all of which it would quietly break.
   So landscape gets an honest card instead of a layout that was never
   drawn for it. Bounded by max-height, so it is phone landscape only —
   a tablet or a desktop browser window never sees it. */
.rotate-note {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 90;
  background: var(--bg);
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 14px;
  padding: 24px;
  text-align: center;
}
.rotate-note p {
  font-family: var(--font-display);
  font-size: 19px;
  letter-spacing: 0.4px;
  color: var(--fg);
}
.rotate-note .rotate-sub {
  font-family: inherit;
  font-size: 13.5px;
  letter-spacing: 0;
  color: var(--fg2);
}
/* The same gold ring the badge wears when it is shut — this is the logo
   at rest, so it should look like the logo at rest. Same 52/26 geometry
   and the same two-layer gradient (gradient on the border box, near-black
   on the padding box) as .brand-badge, so the rim reads as a hairline of
   light rather than a filled shape.
   It turns for as long as it is on screen and never unfolds: there is
   nothing here to unfold to, and a wordmark opening by itself on a
   "turn your phone" card would be an invitation to tap something that
   does nothing. */
.rotate-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 52px;
  height: 52px;
  border: 1.5px solid transparent;
  border-radius: 26px;
  background:
    linear-gradient(150deg, rgba(20, 17, 12, 0.92), rgba(10, 9, 7, 0.92)) padding-box,
    linear-gradient(135deg, #e8b06a, var(--accent) 55%, #8a5722) border-box;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.6);
}
.rotate-note .reel {
  display: block;
  width: 44px;
  height: 44px;
  animation: bb-reel-spin 4.5s linear infinite;
}
.rotate-note .reel svg { display: block; }
@media screen and (orientation: landscape) and (max-height: 560px) {
  .rotate-note { display: flex; }
  /* Nothing underneath should be scrollable or tappable while it shows. */
  #app { pointer-events: none; }
}

/* =========================================================================
   DESKTOP  (>= 1024px)
   =========================================================================
   The same app given room. One block, additive rules, no fork of the
   markup — so "what changes on a wide screen" has a single answer.

   The governing rule after the first attempt: WHERE THE PHONE HAS AN
   ANSWER, USE IT. The desktop should be the phone's design with more
   space, not a second design that happens to share a colour scheme.
   Order of elements, what a rail is, what the excerpt does — all of that
   comes from the phone and stays.
   ========================================================================= */

/* The phone treats these as one flowing column; the wide layout needs the
   reference blocks to be a column of their own. display:contents means the
   wrapper is invisible to the phone's layout entirely. Same for .ov-main
   — on the phone it should not exist as a layout concept either, only on
   wide, where it becomes the review's own grid item (see .ov-body). */
.ov-side { display: contents; }
.ov-main { display: contents; }

/* Section headings are BANDS on the phone too — the same device the wide
   layout uses, at the phone's own 18px gutter rather than the panel's 22px.
   Scoped to .ov-side so the review's own "The Verdict" heading, which lives
   in .ov-main, stays plain, exactly as on the wide layout. .ov-side is
   display:contents here, which removes its BOX but not the element, so the
   selector still matches. Targeting .ov-label generally rather than naming
   each section means anything currently switched off (Where to Watch) is
   banded automatically if it is ever turned back on. */
.ov-side .ov-label {
  /* .ov-body is a flex column with align-items:flex-start — without this a
     band is only as wide as its own text (97px for "Director"). */
  align-self: stretch;
  /* 9 and 5, not 18 and 14: that same flex column has gap:9px, which sits
     BETWEEN every pair of items on top of their margins. 9+9 gives 18px
     above a band and 5+9 gives 14px below, matching the wide panel.
     Measured — the first attempt used 18/14 and rendered 27/23. */
  margin: 9px -18px 5px;
  padding: 8px 18px 7px;
  background: linear-gradient(rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.05)), var(--bg2);
  border-top: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
}
/* A heading nested inside a section wrapper ("Your Take" inside #ov-react)
   is NOT a direct flex item, so the 9px gap does not apply below it and the
   5px alone left 10px. Take the full 14px here and zero whatever the next
   element brings, so the figure holds however that block is built. */
.ov-side > * > .ov-label { margin-bottom: 14px; }
.ov-side > * > .ov-label + * { margin-top: 0; }
/* Two blank lines between the end of the review and the first section.
   The review runs 15px/1.7 = 25.5px a line, so two lines is ~51px; 41px
   here plus .ov-body's 9px gap gives 50. On the wide layout Details is
   the SECOND band in the panel and follows nothing, so the reset there
   (4 classes) outranks this and it stays flush. */
.ov-side .ov-facts-label { margin-top: 41px; }

/* Where to Watch is switched off. The block, its renderer and the TMDB
   proxy behind it all still exist — this is the only thing standing
   between them and coming back. */
.ov-watch { display: none !important; }

@media (min-width: 1024px) {
  :root { --rail: 184px; }

  #app { max-width: none; }

  /* ---------------- the rail ---------------- */
  .tabbar {
    top: 0; bottom: 0; right: auto;
    width: var(--rail);
    height: auto;
    flex-direction: column;
    justify-content: flex-start;
    gap: 2px;
    padding: 168px 12px 20px;      /* room for the masthead above */
    border-top: 0;
    border-right: 1px solid var(--line);
    background: var(--bg2);
  }
  .tab {
    flex: 0 0 auto;
    flex-direction: row;
    justify-content: flex-start;
    align-items: center;
    gap: 12px;
    padding: 11px 12px;
    width: 100%;
    border-radius: 8px;
  }
  .tab:hover { background: rgba(255, 255, 255, 0.04); }
  .tab span { font-size: 12px; letter-spacing: 1.1px; text-transform: uppercase; font-family: var(--font-display); }
  .tab.active { background: rgba(201, 132, 58, 0.1); }

  /* ---------------- the masthead ----------------
     The name has to be ON the page. Folded into a pill that never opens,
     "Blutterbunged" appeared nowhere at all on a desktop — which for the
     site's own home page is indefensible. Here the badge simply IS the
     masthead: reel, wordmark, rule, tagline, always. It stops being a
     button that expands and becomes what it always was on paper. */
  .app-topbar {
    right: auto;
    width: var(--rail);
    height: auto;
    padding: 22px 12px 0;
    justify-content: center;
  }
  .brand-badge {
    width: auto !important;
    height: auto;
    flex-direction: column;
    align-items: center;
    gap: 10px;
    padding: 0 !important;
    border: 0;
    border-radius: 0;
    background: none;
    box-shadow: none !important;
    overflow: visible;
    pointer-events: auto;
  }
  /* The reel keeps the pill's SHUT circle here. On a phone the badge is
     a gold-rimmed circle around the reel that unfolds into the wordmark;
     the desktop masthead above strips the rim off wholesale (border:0,
     background:none, box-shadow:none) and left the drawing floating
     bare against the rail — the one place the mark reads as unfinished
     rather than as the same logo. Same two-gradient trick as
     .brand-badge: the gradient paints the border box and the near-black
     the padding box, so the ring is a hairline of light on dark rather
     than a filled disc. 64px outer against a 53px drawing keeps the
     phone's 52/44 proportion, so the two are the same object at two
     sizes rather than two circles that nearly match. */
  .brand-badge .reel {
    width: 64px;
    height: 64px;
    margin: 0;
    padding: 4px;
    border: 1.5px solid transparent;
    border-radius: 50%;
    background:
      linear-gradient(150deg, rgba(20, 17, 12, 0.92), rgba(10, 9, 7, 0.92)) padding-box,
      linear-gradient(135deg, #e8b06a, var(--accent) 55%, #8a5722) border-box;
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.6);
  }
  /* NOT turning all the time. A logo that never stops is the busiest
     thing on a page you are trying to read. It turns once when you
     arrive and once when you touch it (see burst() in app.js), then
     stops. */
  .brand-badge .reel .reel-spin { animation: none; }
  .brand-badge .reel .reel-spin.spin-burst { animation: bb-reel-spin 4.5s linear 1; }
.brand-badge .brand-block {
    display: flex;
    align-items: center;
    margin: 0;
    opacity: 1;
    transform: none;
    transition: none;
  }
  .brand-badge .wordmark { font-size: 17px; }
  .brand-badge .brand-rule { width: 100%; margin: 5px 0 3px; background: linear-gradient(90deg, rgba(201,132,58,0), var(--accent), rgba(201,132,58,0)); }
  .brand-badge .brand-tagline { font-size: 10px; }

  .view,
  .overlay { left: var(--rail); bottom: 0; }

  /* ---------------- Latest ----------------
     A hero and nothing else is not a home page — it left two thirds of a
     large screen doing nothing. So the film in hand keeps the top of the
     page and the rest of the feed runs beneath it as a strip you can see
     and click. The strip is the same list, so it costs no new data. */
  .feed { height: auto; flex: 1; min-height: 0; }
  #view-home { display: flex; flex-direction: column; }
  .feed-slide {
    display: grid;
    /* Was minmax(260px, 360px) — the poster read as an afterthought
       next to the text column, with most of a wide window going
       unused either side of it. Given more room to actually fill. */
    grid-template-columns: minmax(300px, 460px) minmax(320px, 620px);
    justify-content: start;
    align-content: center;
    gap: clamp(32px, 3.5vw, 56px);
    padding: 48px clamp(24px, 3vw, 56px) 20px;
  }
  /* The zoom needs a box of its own to clip to — a transform on
     .feed-img alone can't contain its own overflow, only an ancestor
     can — so this frame (a no-op everywhere narrower; see the base
     .feed-art rule) carries the sizing/border/shadow the image used to
     carry directly, and the poster now grows inside it on its own zoom
     cycle instead of sitting still beside the text. */
  .feed-art {
    display: block;
    position: relative;
    grid-column: 1;
    justify-self: end;
    width: 100%;
    overflow: hidden;
    border-radius: 6px;
    box-shadow: 0 22px 54px rgba(0, 0, 0, 0.7);
  }
  .feed-slide .feed-img {
    /* Positioned (with no offset) so it shares .feed-blur's stacking
       level: two positioned siblings paint in DOM order (blur, then
       poster), whereas a plain static element paints UNDER a
       positioned sibling regardless of source order — which put the
       blur on top and hid the poster behind it. */
    position: relative;
    width: 100%;
    height: auto;
    max-height: 78vh;
    object-fit: contain;
    -webkit-mask-image: none;
    mask-image: none;
  }
  /* Scrollable in its own right, so a long excerpt can never push the
     "Read review" button out of the slide — which is exactly what put it
     beyond reach on a laptop-height window. */
  .feed-text {
    position: static;
    align-self: center;
    max-width: 62ch;
    max-height: 60vh;
    overflow-y: auto;
    padding: 0;
    background: none;
  }
  /* The excerpt behaves as it does on the phone: shut until asked for.
     Forcing it open was a second design for no reason, and it is what
     made the wide layout feel unrelated to the narrow one. */
  .feed-slide .feed-more-hint { align-self: flex-start; }
  .feed-title { font-size: clamp(38px, 3.4vw, 52px); }

  .feed-arrow {
    pointer-events: auto;
    cursor: pointer;
    width: 46px; height: 46px;
    border-radius: 50%;
    border: 1px solid var(--line);
    background: rgba(14, 13, 11, 0.55);
    animation: none;
    opacity: 1;
    top: 32%;
  }
  .feed-arrow:hover { background: rgba(14, 13, 11, 0.9); border-color: var(--accent); }
  .feed-arrow-l { left: 18px; }
  .feed-arrow-r { right: 18px; }

  /* The strip of everything else in the feed. Same poster size as a
     Collections card, so a poster is one size across the whole app
     rather than a different one per screen. */
  /* A band naming the strip below it — the same device the review
     page's side column uses: a header sitting ON the block it belongs
     to rather than a rule floating above it. Full bleed, and its
     horizontal padding matches the strip's so the words line up with
     the first poster. The strip keeps its own top border, which then
     reads as the line between the header and the posters. */
  .strip-head {
    display: block;
    flex: 0 0 auto;
    padding: 10px clamp(24px, 3vw, 56px) 9px;
    border-top: 1px solid var(--line);
    /* Identical to the section bands in the review panel. Both are "5%
       white", but 5% white composites against whatever is BEHIND it —
       the panel sits on bg2 while this strip sits on its own dark wash
       over the page, so the same value came out two different colours.
       Stacking the tint over an explicit bg2 makes the result the same
       here as there, whatever is underneath. */
    background: linear-gradient(rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.05)), var(--bg2);
    font-size: 11px;
    font-weight: 700;
    letter-spacing: 2.2px;
    text-transform: uppercase;
    color: var(--accent);
  }
  .feed-strip {
    flex: 0 0 auto;
    display: flex;
    gap: 16px;
    overflow-x: auto;
    scrollbar-width: none;
    padding: 16px clamp(24px, 3vw, 56px) 20px;
    border-top: 1px solid var(--line);
    background: rgba(8, 7, 5, 0.55);
  }
  .feed-strip::-webkit-scrollbar { display: none; }
  .feed-strip button {
    flex: 0 0 168px;
    padding: 0;
    background: none;
    border: 0;
    cursor: pointer;
    display: flex;
    flex-direction: column;
    justify-content: flex-start;
    align-items: stretch;
    text-align: left;
    transition: transform 0.2s ease;
  }
  .feed-strip button:hover { transform: translateY(-6px) scale(1.06); }
  .feed-strip button.on { opacity: 1; }
  .feed-strip img {
    width: 168px; height: 241px;
    object-fit: cover;
    border-radius: 10px;
    display: block;
    box-shadow: 0 6px 16px rgba(0, 0, 0, 0.5);
  }
  /* A ring drawn INSIDE the picture. outline sits outside the box and,
     with a border-radius, follows the box rather than the rounded corner —
     which is why it looked the wrong size and shape against the poster. */
  .feed-strip button.on img {
    box-shadow: inset 0 0 0 2px var(--accent), 0 6px 16px rgba(0, 0, 0, 0.5);
  }
  /* Bart, 16 Aug 2026: title + reel rating under the posters, like
   Collections. Reuses the exact .poster-title/.poster-stars markup
   posterCardHTML() already produces (see app.js renderFeedStrip) --
   just shown here since the button hides them by default above. */
.feed-strip button { text-align: left; }
.feed-strip .poster-title { display: -webkit-box; }
.feed-strip .poster-stars { display: block; }
.dots-feed { display: none; }          /* the strip is the position now */

  /* The excerpt is shown here — there is room for it, and a home page
     with a poster and four words of metadata is not a home page. It is
     trimmed to a whole sentence in app.js rather than chopped at a
     character count. */
  .feed-slide .feed-extra { grid-template-rows: 1fr; }
  .feed-slide .feed-more-hint { display: none; }
  .feed-slide { cursor: pointer; }
  .feed-slide .btn-primary,
  .feed-slide .wl-btn,
  .feed-strip button,
  .feed-arrow { cursor: pointer; }
  #view-home .feed-actions { top: 22px; right: 26px; }

  /* ---- arrows on every rail ----
     A rail is a row you flick along and there is nothing to flick with a
     mouse. The wrapper is added by app.js; these are its buttons, sitting
     over each end and fading out when there is nothing that way. */
  .rail-wrap { position: relative; }
  .rail-arrow {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    z-index: 6;
    width: 40px; height: 40px;
    border-radius: 50%;
    border: 1px solid var(--line);
    background: rgba(14, 13, 11, 0.86);
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    opacity: 0.9;
    transition: opacity 0.2s ease, background 0.2s ease, border-color 0.2s ease;
  }
  .rail-arrow:hover { background: rgba(14, 13, 11, 0.98); border-color: var(--accent); }
  .rail-arrow svg { width: 20px; height: 20px; stroke: var(--fg2); stroke-width: 2; fill: none; }
  .rail-arrow:hover svg { stroke: var(--accent); }
  .rail-arrow-l { left: -6px; }
  .rail-arrow-r { right: -6px; }
  .rail-wrap.at-start .rail-arrow-l,
  .rail-wrap.at-end .rail-arrow-r { opacity: 0; pointer-events: none; }

  /* ---------------- the review page ----------------
     Same running order as the phone — title, rating, meta, tagline,
     byline, verdict — because it is the same page. What changes is that
     the reference material sits BESIDE the review instead of after it,
     and the prose is capped at about 68 characters, roughly a paperback.
     The body no longer rides up over the hero: overlapping a 380px
     picture with a 46px title was most of what read as a mess. */
  /* Height follows WIDTH, and width matches .ov-body's CONTENT box —
     .ov-body is capped at 1240px but carries clamp(28px,4vw,64px) of
     side padding, so its columns stop short of that cap. A hero at the
     full 1240px therefore overhung the "Your take"/"Cast" column. Same
     width formula and centring means both edges line up at every window
     size, not just at the cap. */
  .ov-hero {
    height: auto;
    aspect-ratio: 2.4 / 1;
    min-height: 420px;
    max-height: 60vh;
    width: calc(100% - 2 * clamp(28px, 4vw, 64px));
    max-width: calc(1240px - 2 * clamp(28px, 4vw, 64px));
    margin-left: auto;
    margin-right: auto;
  }
  .ov-body {
    display: grid;
    grid-template-columns: minmax(0, 68ch) minmax(260px, 340px);
    gap: clamp(32px, 3.5vw, 60px);
    align-items: start;
    max-width: 1240px;
    margin: 0 auto;
    padding: 34px clamp(28px, 4vw, 64px) 60px;
  }
  .ov-body > *  { grid-column: 1; }
  /* .ov-main and .ov-side are the ONLY two items in this first row —
     .ov-main was display:contents on the phone, so this is the first
     place it becomes a real box. Both auto-size to their own content,
     and the row takes the height of whichever is taller; no row-span
     guess is needed to keep the sidebar "beside everything," and
     .ov-more (below) is no longer blocked from starting right after
     the shorter of the two just because the other one used to claim
     30 rows of column 2 whether or not it needed them. That guess is
     exactly what left a screen of empty space above a short review. */
  .ov-body > .ov-main {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 13px;
    grid-column: 1;
    grid-row: 1;
  }
  .ov-body > .ov-main > .ov-byline { margin-top: 4px; }
  .ov-body > .ov-side {
    display: block;
    grid-column: 2;
    grid-row: 1;
  }
  /* The related rail is a RAIL: full width under the review, scrolling
     sideways, not a column of thumbnails down the edge. */
  .ov-body > .ov-more { grid-column: 1 / -1; }
  .ov-title { font-size: 42px; }
  .ov-review { font-size: 16px; line-height: 1.8; }
  .ov-side > * + * { margin-top: 22px; }
  .ov-cast { flex-wrap: wrap; row-gap: 14px; }
  .ov-footer {
    display: grid;
    grid-template-columns: minmax(0, 68ch) minmax(260px, 340px);
    gap: clamp(32px, 3.5vw, 60px);
    max-width: 1240px;
    margin: 0 auto;
    padding: 14px clamp(28px, 4vw, 64px);
    width: 100%;
  }
  .ov-footer-main { display: flex; gap: 12px; grid-column: 1; }
  .ov-footer-side { display: flex; grid-column: 2; }
  .ov-footer .btn-outline { font-size: 14.5px; padding: 12px 20px; }

  /* ============ EXPERIMENT: ?layout=aligned (reversible) ============
     Makes the review page match Search and Surprise: a fixed 300px right
     column starting at the very TOP of the view and running its full
     height, with the hero held to the centre column. .ov-body becomes
     display:contents so its children become items of .ov-scroll's grid
     directly — that is what lets .ov-side sit BESIDE the hero rather
     than starting below it. Drop the parameter and none of this applies. */
  .bb-layout-aligned .ov-scroll {
    display: grid;
    grid-template-columns: minmax(0, 1fr) 300px;
    align-content: start;
  }
  .bb-layout-aligned .ov-hero {
    /* Full bleed across BOTH tracks. The column below it starts at the
       hero's bottom edge rather than at the top of the view, so this is
       a sidebar beside the review rather than a rail running the whole
       height the way Search and Surprise do — that alignment is the one
       thing given up for a hero that spans the page. */
    grid-column: 1 / -1; grid-row: 1;
    width: auto; max-width: none;
    margin-left: 0; margin-right: 0;
    min-width: 0;
    /* A DEFINITE height, deliberately not aspect-ratio: an auto grid row
       cannot derive one from aspect-ratio during intrinsic sizing, so
       row 1 collapsed to 0px (measured) and the hero — position:relative,
       so painted above its static siblings — overflowed down over the
       title, tagline and details. */
    aspect-ratio: auto;
    /* Taller, and derived rather than guessed. The default desktop hero
       is 2.4:1 with a 420px floor; 30vw here was measuring the WHOLE
       window while the hero only ever occupies the window minus the
       184px tab rail, so it came out around 60px shorter than the
       layout it was meant to match. Computing the same 2.4:1 from the
       width the hero actually gets keeps the two in step at every
       window size, while staying a definite length for the reason
       above. Floor 420px to match the default; ceiling so it cannot
       eat a tall screen on its own. */
    height: clamp(420px, calc((100vw - var(--rail)) / 2.4), 620px);
    min-height: 0;
  }
  .bb-layout-aligned .ov-body { display: contents; }
  .bb-layout-aligned .ov-body > .ov-main {
    grid-column: 1; grid-row: 2;
    display: flex; flex-direction: column; align-items: flex-start; gap: 13px;
    /* Pulled flush to the hero so the title lines up with the Sneak
       Peek band opposite it, rather than pushing that band down to
       meet the title. The 30px that used to be here was what put the
       two 30px out of step. Both columns now start at the hero's
       bottom edge; the band carries its own 8px of inset and the
       title its half-leading, which brings the cap heights level. */
    padding: 6px clamp(28px, 4vw, 64px) 18px;
  }
  .bb-layout-aligned .ov-body > .ov-side {
    grid-column: 2;
    /* Row 2 ONLY. It used to span rows 1-2 so it sat beside the hero and
       started at the very top; now the hero spans both tracks, so the
       column has to begin under it or the two would fight for row 1 and
       the hero would be pushed into a row of its own below the column.
       Still not row 3 — the related rail runs under both columns. */
    grid-row: 2;
    /* start, NOT stretch. Stretching made the panel fill the whole row,
       which is set by the far taller review column — so the background
       carried on about 420px below the share icons and read as an
       enormous bottom padding. It is a panel that wraps its content. */
    align-self: start;
    display: flex;
    flex-direction: column;
    /* A FLOATING panel, not a slab welded to the window edge. The left
       border stays exactly where it was — the gap is taken off the
       right with a margin, so the column narrows from 300 to 288 and
       the page background shows down its right-hand side, the same
       background the centre column sits on.
       A hairline on all four sides rather than only the left: once it
       is floating, a single edge reads as a slab that has been cut off
       rather than as a panel. The shadow is deliberately quiet — on a
       near-black page most of the lift comes from bg2 being lighter
       than bg, and a heavy drop shadow on dark UI is what makes this
       sort of thing look cheap. */
    border: 1px solid var(--line);
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.42), 0 18px 40px -22px rgba(0, 0, 0, 0.85);

    background: var(--bg2);
    /* Panel starts AT the banner. No top padding, and the whole column
       pushed down so nothing of it — background, border, nothing —
       shows above the band. Bottom padding is 14px, the same figure
       every box in here uses under its own content, so the last block
       does not sit in a 30px well. */
    margin-top: 25px;
    margin-right: 25px;
    padding: 0 22px 14px;
  }
  /* A hairline between sections with the SAME gap above and below it. */
  /* SPECIFICITY, and it was the whole bug: the reset ("... > .ov-side > *",
     four classes) OUTRANKED the hairline rules (two classes), so every
     section got margin-top:0 ABOVE its rule while keeping 18px padding
     BELOW it. The line sat hard against the block above and floated away
     from the one below. The reset is now weaker than what overrides it. */
  .bb-layout-aligned .ov-side > * { margin-top: 0; }
  /* .ov-facts has a "Details" heading of its own now, so it takes the
     band like every other section and no longer needs the hairline it
     used to carry as the one headerless block in the column. */
  .bb-layout-aligned .ov-body > .ov-side > .ov-facts-label,
  .bb-layout-aligned .ov-body > .ov-side > #ov-director-label,
  .bb-layout-aligned .ov-body > .ov-side > #ov-cast-label,
  .bb-layout-aligned .ov-body > .ov-side > #ov-react,
  .bb-layout-aligned .ov-body > .ov-side > .ov-watch,
  .bb-layout-aligned .ov-body > .ov-side > .ov-share-label {
    margin-top: 18px;
  }
  /* Same source-order trap as the hairline reset before it: an override
     of equal specificity has to come AFTER the rule it overrides, or it
     simply loses. The Details band takes 14px under the buttons rather
     than the 18px every other section start uses — 14 being the figure
     the Details box already uses inside itself, so the buttons get
     matching air above and below. */
  .bb-layout-aligned .ov-body > .ov-side > .ov-facts-label { margin-top: 0; }
  /* The ACTIONS block is the first section; the facts block sits after it
     and keeps its rule — that is the line that was missing under Movie
     Stills. Only the actions block has nothing above it. */
  .bb-layout-aligned .ov-body > .ov-side > .ov-actions-side { border-top: 0; margin-top: 0; padding-top: 0; }
  /* ---- Section headers sit in a band, not under a hairline ----
     A rule ABOVE a header separates it from what came before but
     attaches it to nothing; a band reads as the header OF the block
     beneath it, which is what these are (Share, Your Take, Director,
     Cast, Where to Watch). Pulled out by exactly the column's 22px of
     side padding so it meets both edges — a band stopping short of them
     reads as an indented sub-heading rather than the start of a section.
     A very low-contrast white rather than a colour: it has to sit under
     accent lettering without competing with it, and the column already
     has its own bg2 fill, so anything stronger banded the page. */
  .bb-layout-aligned .ov-side .ov-label {
    margin-left: -22px;
    margin-right: -22px;
    margin-bottom: 14px;
    padding: 8px 22px 7px;
    background: rgba(255, 255, 255, 0.05);
    border-top: 1px solid var(--line);
    border-bottom: 1px solid var(--line);
  }
  /* Director and Cast were still at the phone's sizes in a column with
     room to spare. Up about a fifth: 64 → 76px portraits, 11.5 → 13px
     names, 10 → 11px roles, and the card grows with them (78 → 92px).
     Two still sit side by side: 92 + 14 + 92 = 198 inside the 256px of
     content width, and three never fitted at the old size either
     (78 x 3 + 28 = 262), so the wrapping is unchanged. */
  .bb-layout-aligned .ov-side .cast-card { flex: 0 0 92px; }
  .bb-layout-aligned .ov-side .cast-ava {
    width: 76px;
    height: 76px;
    font-size: 26px;
  }
  .bb-layout-aligned .ov-side .cast-name { font-size: 13px; }
  .bb-layout-aligned .ov-side .cast-role { font-size: 11px; }
  /* The facts were the smallest type on the page at 12.5px, and they are
     the part most likely to be read at a glance. */
  .bb-layout-aligned .ov-side .ov-facts { font-size: 14px; line-height: 1.5; }
  .bb-layout-aligned .ov-side .ov-facts-line + .ov-facts-line { margin-top: 5px; }
  /* Everything in the column reads down the middle. */
  .bb-layout-aligned .ov-body > .ov-side { text-align: center; }
  .bb-layout-aligned .ov-side .ov-cast { justify-content: center; }
  /* The same hover the share tiles and cast portraits use, so every
     pressable thing in this column behaves alike. */
  .bb-layout-aligned .ov-side .react-chip:hover,
  .bb-layout-aligned .ov-actions-side .btn-outline:hover {
    border-color: var(--accent);
    background: rgba(201,132,58,0.09);
    color: var(--fg);
  }
  .bb-layout-aligned .ov-side .react-chip.on:hover { background: var(--accent); color: var(--accent-ink); }
  /* "Your take" is a label INSIDE a section, not a section start. Its
     inherited 15px top margin stacked on the section's 18px padding,
     which is why that hairline sat closer to the block above than below. */
  .bb-layout-aligned #ov-react .ov-label { margin-top: 0; }

  /* Director, Cast, then Your Take, then Share. */
  /* Caps the open top of the column. It is the FIRST thing in there, so
     it is pulled up by the column's own 26px of top padding as well as
     the 22px at the sides — the band then meets all three edges and the
     column reads as closed off rather than as content floating in a
     panel with a gap above it. */
  .bb-layout-aligned .ov-actions-label { display: block; order: 0; }
  .bb-layout-aligned .ov-body > .ov-side > .ov-actions-label { margin-top: 0; }
  .bb-layout-aligned .ov-actions-side { order: 1; }
  /* Details heading and the facts share order 2 rather than renumbering
     the whole column: equal order falls back to DOM order, and the
     heading precedes the block in the markup. */
  .bb-layout-aligned .ov-facts-label { display: block; order: 2; }
  .bb-layout-aligned .ov-facts { order: 2; }
  .bb-layout-aligned #ov-director-label { order: 3; }
  .bb-layout-aligned #ov-director { order: 4; }
  .bb-layout-aligned #ov-cast-label { order: 5; }
  .bb-layout-aligned #ov-cast { order: 6; }
  .bb-layout-aligned #ov-react { order: 7; }
  .bb-layout-aligned .ov-share-label { order: 8; }
  .bb-layout-aligned .ov-share-inline { order: 9; }
  .bb-layout-aligned #ov-watch { order: 10; }
  .bb-layout-aligned .ov-body > .ov-more {
    grid-column: 1 / -1;
    grid-row: 3;
    /* The rule runs the full width of both columns, marking the rail as
       its own thing rather than a continuation of either. */
    border-top: 1px solid var(--line);
    /* 6px gave a hairline with nothing above it, so the rail still read
       as attached to what came before. The side margins must be reset:
       the phone rule bleeds this block -18px each way to line the rule
       up with the bands, and inherited here that pushed the rail 18px
       past both edges of the view. */
    margin: 30px 0 0;
    padding: 26px clamp(28px, 4vw, 64px) 50px;
  }
  .bb-layout-aligned .ov-footer {
    grid-template-columns: minmax(0, 1fr) 300px;
    max-width: none;
    padding-left: clamp(28px, 4vw, 64px);
    padding-right: 0;
  }
  /* Actions live in the column now, so the bar at the bottom goes. */
  .bb-layout-aligned .ov-footer { display: none; }
  .bb-layout-aligned .ov-actions-side { display: flex; flex-direction: column; gap: 9px; /* 14px: the same air the Details box keeps under its own
       content, so the buttons sit evenly between the two bands. */
    margin-bottom: 14px; }
  .bb-layout-aligned .ov-actions-side .btn-outline { width: 100%; justify-content: center; background: none; border-color: var(--line); color: var(--fg); font-size: 13px; padding: 11px 14px; }
  .bb-layout-aligned .ov-share-label { display: block; margin-top: 0; }
  .bb-layout-aligned .ov-share-inline {
    /* One row, however many targets there are: flex with equal basis
       rather than an auto-fit grid, which wrapped 6 tiles onto 2 rows
       once each one wanted 70px. */
    display: flex;
    gap: 7px;
    /* 0, not 14: the column itself supplies the 14px under its last
       block. Both together gave 28px. */
    margin-bottom: 0;
  }
  .bb-layout-aligned .ov-share-inline .share-target {
    flex: 1 1 0;
    min-width: 0;
    /* Icons only. The label is a bare TEXT NODE next to the svg, so
       there is no element to hide — font-size:0 collapses it while the
       svg keeps its own explicit size. */
    font-size: 0;
    padding: 9px 0;
    border-radius: 10px;
    justify-content: center;
    /* The base tile is a flex column with a gap between icon and
       label. font-size:0 collapses the label but NOT the gap, which
       left 8px of dead space under every icon. */
    gap: 0;
  }
  .bb-layout-aligned .ov-share-inline .share-target svg { width: 19px; height: 19px; }

  /* ---------------- Collections ----------------
     Rails stay RAILS. Wrapping them into blocks turned a row you flick
     along into a wall of posters and lost the one thing a rail is for.
     They keep scrolling sideways; there are simply more of them visible
     and the cards are bigger. */
  .collections-scroll { padding: 0 clamp(20px, 2.5vw, 48px); }
  .hero-wrap, .rail-head { max-width: 1400px; margin: 0 auto; }
  .rail-head { padding: 30px 4px 12px; }
  .rail-head h2 { font-size: 14px; }
  .rail {
    max-width: 1400px;
    margin: -16px auto 0;
    padding: 16px 4px 4px;
    gap: 16px;
  }
  .poster-card { flex: 0 0 168px; }
  .poster-thumb { width: 168px; height: 241px; }
  .still-card { flex: 0 0 min(560px, 45%); }
  .hero-track { padding: 2px 4px 0; }

  /* ---------------- Search ----------------
     Same move as Surprise: the criteria column sits on the right, the
     results in the middle. grid-column is set explicitly on both so
     the layout doesn't depend on which is first in the markup. */
  /* grid-template-rows: minmax(0, 1fr) — not the default implicit
     "auto" — is what actually matters here. An auto row sizes to its
     tallest item's CONTENT height, not to the grid's own (definite)
     container height; .search-top's overflow-y:auto never got a chance
     to engage, because nothing capped the row at the container's
     height in the first place, so the row just grew to fit the whole
     filter list and the results column — a separate item in the SAME
     oversized row — started wherever that row happened to end. The
     minmax(0, ...) additionally overrides grid's default min-height:auto
     on both children, which is the second half of the same bug: without
     it a scrolling child's own content can still force the track taller
     than the container even once the track itself is capped. */
  #view-search { display: grid; grid-template-columns: minmax(0, 1fr) 300px; grid-template-rows: minmax(0, 1fr); }
  .search-top {
    grid-column: 2;
    grid-row: 1;
    min-height: 0;
    padding: 26px clamp(20px, 2vw, 32px) 24px;
    border-left: 1px solid var(--line);
    overflow-y: auto;
  }
  .search-top .filter-panel[hidden] { display: block !important; }
  .search-top .filter-row .chev { display: none; }
  .search-top .filter-row { pointer-events: none; }
  .search-list {
    grid-column: 1;
    grid-row: 1;
    min-height: 0;
    overflow-y: auto;
    padding: 26px clamp(24px, 3vw, 56px) 40px;
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
    gap: 4px 28px;
    align-content: start;
  }

  /* ---------------- Watchlist ----------------
     The two lists sit side by side now that there is room — still each
     a plain vertical list of rows, not a card grid; only the wide
     layout adds the second column, columns run independently in normal
     flow (no forced equal height), and the page scrolls as one. */
  .wl-head { max-width: 1240px; margin-left: auto; margin-right: auto; }
  .wl-columns {
    max-width: 1240px;
    margin: 0 auto;
    padding: 4px clamp(20px, 2vw, 32px) 24px;
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 0 clamp(28px, 3vw, 56px);
    align-items: start;
  }
  .wl-col + .wl-col { border-top: 0; margin-top: 0; padding-top: 0; border-left: 1px solid var(--line); padding-left: clamp(28px, 3vw, 56px); }
  .wl-list { padding: 6px 0 0; }
  /* The list was still the phone's compact thumbnail (60x88) here — a
     tiny image in a much wider column. Doubled to match the room. */
  .wl-list .row-thumb,
  .search-list .row-thumb { width: 120px; height: 176px; border-radius: 10px; }

  /* ---------------- Surprise ----------------
     Settings and the three actions move out of the poster and into a
     column of their own (see surSideColumn in app.js), where they stay
     put for as long as you are on Surprise. Same shape as Search: a
     control you can see is a control you use. */
  /* grid-template-rows: minmax(0, 1fr) so this row is capped at the
     view's own (definite) height rather than sized to .sur-rail's full
     content — see the identical, visibly-broken case in Search just
     above for what happens without it. #surprise-result doesn't
     currently share the row (it's positioned absolutely, so it never
     showed this symptom), but there's no reason to leave the same trap
     armed here too. */
  #view-surprise { display: grid; grid-template-columns: minmax(0, 1fr) 300px; grid-template-rows: minmax(0, 1fr); }
  /* Explicitly pinned to the second (right) track regardless of DOM
     order — surSideColumn() in app.js inserts the rail as the view's
     first child, which used to matter when the rail was column one.
     grid-column makes the visual position independent of that. */
  .sur-rail {
    grid-column: 2;
    grid-row: 1;
    min-height: 0;
    border-left: 1px solid var(--line);
    background: var(--bg2);
    overflow-y: auto;
    padding: 26px 22px 30px;
    display: flex;
    flex-direction: column;
    gap: 18px;
    z-index: 4;
  }
  .sur-rail .sur-btns { flex-direction: column; margin: 0; width: 100%; }
  .sur-rail .sur-btns .btn-outline,
  .sur-rail .sur-btns .btn-primary { width: 100%; justify-content: center; font-size: 12.5px; }
  /* The settings sheet stops being a sheet: no sliding down over the
     poster, no dismissing, just a panel that is there. */
  .sur-rail .sur-sheet {
    position: static;
    transform: none;
    background: none;
    border: 0;
    box-shadow: none;
    padding: 0;
    max-height: none;
  }
  .sur-rail .sur-sheet-inner { padding: 0; }
  .sur-rail .sur-sheet-done { display: none; }
  .sur-rail .sur-settings-btn { display: none; }

  /* ---- Right-column hover: Search filters + Surprise settings ----
     Everything pressable in a right-hand column lights the same way:
     the accent border over a faint accent wash that the share tiles,
     the cast portraits and the review page's own side column already
     use. On a phone these panels are sheets you tap and dismiss, so
     nothing was ever missing; parked permanently open in a column on
     the wide layout they became the only pressable things on the site
     that sat completely dead under a cursor, which reads as decoration
     rather than as controls. */
  .search-top .chip:hover,
  .sur-rail .chip:hover,
  .sur-rail .btn-outline:hover {
    border-color: var(--accent);
    background: rgba(201, 132, 58, 0.09);
    color: var(--fg);
  }
  /* A chosen chip is already drawn IN the accent colour, so the shared
     rule's color:var(--fg) would grey it out the moment you pointed at
     it — reading as though hovering had dropped the selection. Search
     marks a chosen chip .active; Surprise's ratings use .rating-on. */
  .search-top .chip.active:hover,
  .sur-rail .chip.active:hover,
  .sur-rail .chip.rating-on:hover { color: var(--accent); }
  /* Ratings switched OFF are held at 0.45 opacity, which drags the
     hover wash down with them to almost nothing. They lift instead:
     still visibly excluded, but plainly a thing you can click back on. */
  .search-top .chip.rating-off:hover,
  .sur-rail .chip.rating-off:hover { opacity: 0.8; }
  /* Transitions live on the base elements, not the :hover rules, so the
     colour eases back out again when the cursor leaves as well as in. */
  .search-top .chip,
  .sur-rail .chip,
  .sur-rail .btn-outline {
    transition: border-color 0.16s ease, background-color 0.16s ease, color 0.16s ease, opacity 0.16s ease;
  }
  /* The poster panel is absolutely positioned, so it takes no notice of
     the grid — it has to be told where the column starts, or it lies
     underneath the settings. */
  /* Settings column sits on the right now, so the poster panel's own
     300px is given up on its RIGHT edge instead of its left. */
  #surprise-result { right: 300px; }
  .sur-deal { right: 300px; }

  #surprise-result .sur-bg {
    /* Full width of the panel now — was capped first at min(560px,62%),
       then min(900px,84%), and stayed "an afterthought island" at both
       sizes because it was still a cap, not a fill. growTransform (in
       app.js) already describes the actual intent in its own comment:
       the dealt card scales to the width of the panel the SAME WAY this
       poster is laid out, full width, not covering it — a cap here just
       leaves that promise half kept. */
    width: 100%;
    left: 0;
  }
  /* The full width of the panel. Held to 760px in the middle it left the
     title and the details in a narrow column with the picture towering
     over them, which is the opposite of what the room was for. Only the
     one paragraph of prose keeps a measure — a 900px line of body text
     is unreadable however much space there is to fill. */
  .sur-body {
    max-width: none;
    left: 0;
    right: 0;
    transform: none;
    padding-left: clamp(28px, 4vw, 72px);
    padding-right: clamp(28px, 4vw, 72px);
  }
  .sur-verdict { max-width: 78ch; }
  /* Three lines of air under "Read review". On a phone the three action
     buttons sit directly below this block, so it needs nothing; here
     surSideColumn() lifts them out into the rail, which leaves the
     button as the last thing on the page with the panel's edge hard
     against it. 3 x the verdict's 20.25px line (13.5px at 1.5).
     Padding rather than margin because .feed-extra collapses via
     grid-template-rows:0fr and .feed-extra-inner is the element
     carrying the overflow:hidden — margin would escape that clip and
     hold the gap open even while the block is shut. */
  #surprise-result .feed-extra-inner { padding-bottom: 61px; }
  #sur-title { font-size: clamp(38px, 3.6vw, 54px); }
  .sur-actions { right: clamp(24px, 3vw, 48px); }

  /* ---------------- About ---------------- */
  .about-scroll { max-width: 860px; margin: 0 auto; }
}


/* BB-HOME-EXTRAS:START -- additive, wide-layout only, reversible.
   Matching blocks live in index.html and app.js (search
   BB-HOME-EXTRAS). Delete all three to remove entirely, or just
   flip HOME_EXTRAS_ENABLED to false in app.js to turn it off.
   Rewritten 12 Aug 2026: was a Top Rated rail, now two
   half-width discovery cards -- see the app.js comment for why.
   Left/right inset uses the SAME clamp(24px, 3vw, 56px) as
   .strip-head/.feed-strip just above it on this page, so the
   whole page lines up on one edge instead of each section
   picking its own margin. */
/* Bart, 12 Aug 2026: "Our Latest Reviews" sat between the hero and the
   strip below it, reading as a hard break rather than one continuous
   section. Hidden on wide layout only -- the strip itself (#feed-strip)
   stays and now flows straight on from the hero. Mobile is untouched:
   .strip-head is already display:none there by default (see the base
   rule earlier in this file); this just re-applies that at the same
   >=1024px breakpoint that turns it back on. */
@media (min-width: 1024px) {
  #strip-head { display: none; }
}

.home-extra-rails { display: none; }
@media (min-width: 1024px) {
  .home-extra-rails { display: block; }
}
.home-extra-rails {
  background: var(--bg2);
  border-top: 1px solid var(--line);
  padding: 28px clamp(24px, 3vw, 56px) 36px;
}
.home-discover-head { margin-bottom: 18px; }
.home-discover-head h2 {
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 2.4px;
  text-transform: uppercase;
  color: var(--dim);
  margin: 0 0 4px;
}
.home-discover-sub { font-size: 13px; color: var(--fg2); margin: 0; font-style: italic; }
.home-cta-row { display: flex; gap: 16px; align-items: stretch; }
.home-cta-card {
  flex: 1 1 0;
  min-width: 0;
  border-radius: 10px;
  border: 1px solid var(--line);
  overflow: hidden;
}
.home-cta-surprise {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 22px 26px;
  background: linear-gradient(135deg, rgba(201, 132, 58, 0.16), var(--card));
  cursor: pointer;
  text-align: left;
}
.home-cta-surprise:hover { border-color: var(--accent); }
.home-cta-body { display: flex; flex-direction: column; gap: 4px; }
.home-cta-kicker {
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--accent);
}
.home-cta-title { font-size: 20px; font-weight: 600; color: var(--fg); }
.home-cta-sub { font-size: 14px; color: var(--fg2); }
.home-cta-arrow { font-size: 30px; line-height: 1; color: var(--accent); flex: 0 0 auto; }
/* The spotlight card has no button semantics -- it just sits there
   and cycles, so it keeps the shared border/radius from .home-cta-card
   but drops the pointer cursor and padding (the images go edge to edge). */
.home-cta-spotlight {
  position: relative;
  flex: 0 0 min(560px, 45%);
  aspect-ratio: 16 / 9;
  background: var(--card);
  cursor: pointer;
}
.home-cta-spotlight:hover { border-color: var(--accent); }
.home-spotlight-imgs { position: absolute; inset: 0; }
.home-spotlight-imgs .still-card {
  position: absolute;
  inset: 0;
  opacity: 0;
  border-radius: 0; /* the shell already clips to 16px -- avoid doubling the curve */
  transition: opacity 1.2s ease;
}
.home-spotlight-imgs .still-card.on { opacity: 1; }


.home-spotlight-label {
  position: absolute;
  left: 12px;
  top: 12px;
  z-index: 2;
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--bg);
  background: var(--accent);
  padding: 4px 8px;
  border-radius: 4px;
}
/* A static fan of three card backs -- the same brushed-gold reel
(buildReel, shared with the logo and the real deal cards) the
Surprise page shows face-down, just laid out with a fixed CSS
rotation instead of the real deal's JS-measured positions. Not
wired to openDeal in any way, so it can't drift out of sync with
the real animation -- purely a "there will be three cards" preview.
Added 12 Aug 2026 (v3) replacing the removed subtext + arrow. */
.home-deal-fan {
  position: relative;
  flex: 0 0 auto;
  width: 150px;
  height: 130px;
}
.home-deal-mini {
  position: absolute;
  top: 6px;
  width: 84px;
  height: 118px;
  border-radius: 8px;
  background: linear-gradient(145deg, #211d16, #120f0c);
  border: 1px solid rgba(232, 192, 101, 0.35);
  box-shadow: 0 8px 18px rgba(0, 0, 0, 0.45);
  display: flex;
  align-items: center;
  justify-content: center;
}
.home-deal-mini svg { width: 46px; height: 46px; }
.home-deal-mini .deal-reel { animation: none; }
.home-deal-mini:nth-child(1) { left: 4px; transform: rotate(-9deg) translateY(6px); z-index: 1; }
.home-deal-mini:nth-child(2) { left: 33px; transform: rotate(0deg); z-index: 2; }
.home-deal-mini:nth-child(3) { left: 62px; transform: rotate(9deg) translateY(6px); z-index: 1; }

/* BB-HOME-EXTRAS:END */
