/* ============================================================
   SELF-HOSTED FONTS — added 4.49.0

   These used to come from fonts.googleapis.com with display=swap,
   which meant every review painted in a fallback face and then
   reflowed when Oswald and Inter arrived. That reflow was the last
   remaining "double load" — not a second render, just the text
   changing shape underneath itself.

   Same-origin now, so there is no DNS lookup and TLS handshake to a
   third party before the fonts can even begin downloading, and they
   are preloaded from the shell's <head>. font-display:block rather
   than swap: with the files already in flight there is nothing to
   swap TO later, and block guarantees the text is never drawn twice.

   Paths are relative on purpose. A stylesheet resolves url() against
   ITS OWN address, not the page's, so these stay correct at /, at
   /movie/<slug>/, and on a staging copy in a subdirectory.

   Oswald 500/600/700 and Inter variable (optical sizing 14-32) from
   @fontsource, SIL Open Font License. 190 KB, cached indefinitely.
   ============================================================ */
@font-face{font-family:Oswald;font-style:normal;font-weight:500;
  src:url(bb-oswald-500.woff2) format('woff2');font-display:block}
@font-face{font-family:Oswald;font-style:normal;font-weight:600;
  src:url(bb-oswald-600.woff2) format('woff2');font-display:block}
@font-face{font-family:Oswald;font-style:normal;font-weight:700;
  src:url(bb-oswald-700.woff2) format('woff2');font-display:block}
@font-face{font-family:Inter;font-style:normal;font-weight:100 900;
  src:url(bb-inter.woff2) format('woff2-variations');font-display:block}
@font-face{font-family:Inter;font-style:italic;font-weight:100 900;
  src:url(bb-inter-italic.woff2) format('woff2-variations');font-display:block}

/* ============================================================
   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; }
/* Below the rail breakpoint the Blutterbunged reel floats over the top
   left of every view, so the page's first heading has to start under it
   — otherwise "Editor's Pick of the Day" reads out from behind the logo.
   Everything after it still scrolls up under the logo as before. */
.collections-scroll > .rail-head:first-child {
  padding-top: calc(var(--topbar-h) + var(--sat) + 18px);
}
.home-end { height: 24px; }

/* ---------------- hero (editor's picks) ---------------- */
/* The hero sits under its own heading now (5.16.1), so it hugs it the
   way a rail hugs its heading — the 16px the rails cancel with a
   negative margin was left here as a gap twice the size of every other
   one on the page. Clearing the floating logo is the FIRST HEADING's job
   now; see .collections-scroll > .rail-head:first-child below. */
/* ═══════════ THE PICK IS A CARD ON EVERY LAYOUT (5.16.14) ═══════════
   Bart: "make the mobile version have a card around the image (but no
   excerpt)". The same line, ground, aura and hairline the wide layout
   wears — the excerpt stays a wide-layout thing, because a phone has no
   empty space beside the picture to put it in, and the title and rating
   stay on the artwork where they have always been.

   The card is inset 10px rather than the 16px the picture used to sit
   at, and carries 8px inside, so the picture comes out 6px narrower than
   before on a 390px screen rather than 22px narrower — near enough to
   keep it level with the rails below, and the frame lands just outside
   the line their posters start on. The inset moves from the track's
   padding (where it was) to this margin (where the card needs it). */
.hero-wrap {
  position: relative;
  padding-top: 0;
  margin: 0 10px;
  padding: 8px;
  border: 1px solid var(--accent);
  border-radius: 16px;
  /* The catalogue card's ground and the aura, both shared with the wide
     layout's copy of this card further down — change them together. */
  background: linear-gradient(135deg, rgba(201, 132, 58, 0.16), #1c1913);
  /* THE AURA, IN TWO LAYERS. Bart, 21 Sep 2026: "a slightly more
     pronounced aura, both on the homepage hero and the collections
     here (pick of the day)". One shadow could only be made stronger
     by widening it, and the homepage card sits inside a slide that
     clips — so a wider glow would have been cut off at the edge
     rather than seen. Two layers instead: a tight bright one that
     lives well inside the clip, and the original soft one carrying
     more of the colour. Reach goes 22px -> 28px, brightness roughly
     half again. Shared with .feed-card at the end of this file —
     change them together. */
  box-shadow:
    0 0 14px -4px rgba(201, 132, 58, 0.45),
    0 0 38px -10px rgba(201, 132, 58, 0.72);
}
/* ═══ THE HAIRLINE ROUND THE ARTWORK, AND WHY IT IS NOT AN OUTLINE ═══
   Bart, 21 Sep 2026: "on the collections here, the hairline disappears at
   the bottom of the image?" It was an outline pulled inside by its own
   width (outline-offset:-1px), which is the tidiest way to draw a ring
   that costs the picture no height — and it was being painted OVER at
   the bottom by .hero-card::after, the scrim that darkens the picture so
   the title can be read on it. Positioned children with z-index >= 0
   paint above an outline, and that scrim is inset:0, so it covered the
   whole ring; only the top and sides showed, because that is where the
   gradient is still transparent. Measured: (139,106,61) along the top
   and sides, (95,73,43) along the bottom — the same line under 35%
   black.
   So the ring is drawn BY the scrim instead, as an inset shadow on the
   one layer that is already above the picture. Nothing can cover it any
   more, because there is nothing above it but the words. */
.hero-card::after {
  border-radius: inherit;
  /* TWO RINGS, NOT ONE. A single 35% gold line reads against a dark
     frame and disappears against a bright one — the foot of '71 is
     pavement, and Bart saw no change there even after the line stopped
     being painted over. So the gold is brighter and a dark pixel sits
     just inside it, separating the line from the picture whatever the
     picture happens to be. Inset shadows paint in the order written,
     so the gold covers pixel 1 and the dark one shows at pixel 2.
     Shared with .feed-art::after at the end of this file. */
  box-shadow:
    inset 0 0 0 1px rgba(232, 192, 101, 0.55),
    inset 0 0 0 2px rgba(0, 0, 0, 0.38);
}
.hero-track {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  gap: 12px;
  /* The 16px that used to inset the picture from the screen is the
     card's margin now — see .hero-wrap above. */
  padding: 0;
  /* SIDEWAYS ONLY (5.16.2). Safari on iOS rubber-bands a scroller in BOTH
     axes, so a finger on one of these rows could drag the posters up,
     down and around inside it — Bart on an iPhone. overflow-y: hidden
     means there is no vertical scroll to rubber-band, which is the whole
     of the fix.

     IT ALSO CARRIED touch-action: pan-x FOR ONE DAY, and that was a
     mistake: pan-x does not mean "send the vertical swipe to the page",
     it means no vertical gesture may START on this element at all. So on
     the Collections page a finger landing on any row — which is most of
     the page — could not scroll it. Bart: "I can only swipe up that page
     by finding the narrow band between rails". The row is no longer a
     vertical scroller, so the default is already right: the browser
     hands the vertical pan to the nearest ancestor that can take it,
     which is the page. */
  overflow-y: hidden;
  touch-action: auto;
}
.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);
}

/* ---- the pick, in words, beside the picture (5.16.8) ----
   PHONE FIRST: there is nothing to say here, because there is no empty
   space to say it in — the card is full width and carries its own
   overlay. The wide layout turns this on; see the .hero-wrap rules in
   the 900px band. */
.hero-side { display: none; }
/* The card's own mark — see #hero-pill in index.html. There is no card on
   a phone, so there is nothing for it to sit in the corner of. */
#hero-pill { display: none; }

/* ---- 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;
}
.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;
  /* ABOVE THE RAIL, and it has to be said explicitly. .rail carries
     margin-top:-16px against padding-top:16px — headroom for a hovered
     poster to grow into without being clipped by overflow-x — which
     means the rail's box overlaps the bottom 16px of this head. The rail
     is the later sibling and carries cursor:grab, so it won the pointer
     over the lower half of every "See all": the cursor turned into a
     grab hand and the button only answered if you aimed at its top few
     pixels. The hit area was being sized by how big a poster can get on
     hover, which has nothing to do with it.
     Lifting the head fixes the pointer without taking the headroom away.
     Nothing is hidden by it — .rail-head has no background, so a poster
     growing up into this strip still shows through. */
  position: relative;
  z-index: 1;
}
.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);
  /* A target worth aiming at. The text is 12px, so the button was about
     14px tall — precision work with a mouse and worse with a thumb. The
     negative margin cancels the padding, so the row is laid out exactly
     as before and only the clickable area grows. */
  padding: 8px 10px;
  margin: -8px -10px;
}
.rail {
  display: flex;
  gap: 12px;
  overflow-x: auto;
  /* See .hero-track: no vertical scroll of its own, or an iPhone drags
     the whole row around inside its own box — and touch-action left
     alone, or a finger on a row cannot scroll the page. */
  overflow-y: hidden;
  touch-action: 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; }
/* THE ARROWS EXIST ONLY WHERE THEY ARE POSITIONED. Everything about them
   — position:absolute included — lives in the 900px band, so below it
   they were plain buttons in the normal flow. That is invisible on a
   phone loaded as a phone, because app.js only builds the wrapper on the
   wide layout; but the wrapper is never taken down again, so DRAGGING a
   desktop window narrow left two static buttons sitting under every row.
   Bart, 19 Sep 2026: "the image moves up and leaves an empty line at the
   bottom" — measured at 20px under the Editor's Pick. */
.rail-arrow { display: none; }
.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; }
/* ═══════════ LIFT ON HOVER, WHERE HOVERING IS A REAL THING ═══════════
   A touchscreen has no hover, so it fakes one: a tap leaves :hover stuck
   on whatever was touched until something else takes it. Swiping a rail
   starts with a touch on a poster, so a rail left one poster sitting
   proud of the row after the finger had gone. Bart, 19 Sep 2026: "it
   enlarges a bit but it doesn't easily go back so it looks odd ... just
   remove the feature altogether on mobile".
   (hover: hover) and (pointer: fine) is the honest test for that — it
   asks the device whether it can hover, rather than guessing from the
   window's width, so a touchscreen laptop is treated as the touchscreen
   it is. The same guard is on the two other lifts below; the film strip's
   is inside the wide band already and stays as it is. */
@media (hover: hover) and (pointer: fine) {
  .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; }

/* ---- Review / Suggestion marker (5.11.0) ----
   A strip attached UNDER a portrait poster — outside the artwork, which
   usually carries the title — and the same style as a pill where a
   landscape card already has its title on the picture. One quiet colour
   for both kinds: a gold "Suggested" read as a highlight and drew the eye
   more than a review did, which is backwards. */
.mk-strip {
  height: 20px;
  margin-top: -1px;
  border-radius: 0 0 10px 10px;
  display: flex;
  align-items: center;
  justify-content: center;
  font: 500 12px/1 var(--font-display);
  letter-spacing: 1.4px;
  text-transform: uppercase;
  white-space: nowrap;
  overflow: hidden;
  background: #1d1a14;
  color: var(--fg2);
  border: 1px solid #2b2820;
  border-top: 0;
}
/* The picture's bottom corners go square where the strip joins it. A
   modifier on elements whose radius is set by several contextual rules,
   hence the !important. */
.mk-flat { border-bottom-left-radius: 0 !important; border-bottom-right-radius: 0 !important; }
.mk-pill {
  position: absolute;
  top: 10px;
  right: 10px;
  z-index: 3;
  border-radius: 999px;
  padding: 4px 10px;
  background: rgba(29, 26, 20, 0.9);
  color: var(--fg2);
  border: 1px solid #2b2820;
  font: 500 12px/1 var(--font-display);
  letter-spacing: 1.4px;
  text-transform: uppercase;
  pointer-events: none;
}
.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; }
/* And so is the card wrapper: on a phone the slide IS the card, full
   bleed, so this box must not exist. The wide layout turns it into the
   real one — see the artwork-card block at the end of this file. */
.feed-card { 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;
}
/* WHICH BOX SCROLLS WHEN THE PANEL IS TOO TALL — the excerpt, not the
   panel. .feed-text has a max-height (92% on a phone, 60vh wide) and
   overflow-y:auto, so on a short window the whole block used to become
   one scroller with the "Read review" button below its fold. app.js
   then scrolled the button into view, which scrolled the actor, title,
   tagline and the first lines of the excerpt off the TOP — the review
   opening halfway through a sentence that Bart reported on 10 Sep 2026.
   (It self-corrected a few seconds later because anything that re-laid
   the panel out clamped that scrollTop back to 0, which read as the
   text "reloading".)

   Letting the excerpt absorb the overflow instead keeps every fixed
   line and the button painted at all times, and the text always starts
   at line one. min-height:0 is what allows the <p> to shrink below its
   content height inside a flex column; .feed-extra needs the same or
   the grid row refuses to give anything up. NO min-height floor on the
   <p> — a floor stops it shrinking, and .feed-extra-inner's
   overflow:hidden then clips the button instead of scrolling to it,
   which is worse than what we started with (measured: button outside
   the panel at 320px and 260px with a 3.2em floor, inside at every
   height without one). Verified 588 → 300px: panel overflow 0 at all,
   button inside the panel at all. */
.feed-extra { min-height: 0; }
.feed-extra-inner > .feed-review-text {
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
}
/* Clearance for the Latest pill, which floats at bottom:14px over the
   poster (see BB-LATEST-BANNER) — and a couple of pixels at the top so the
   first line of the excerpt is not sitting on the rating above it.

   ONLY WHILE OPEN. Padding on a permanently-present box is padding the
   collapsed panel too: grid-template-rows:0fr zeroes the CONTENT height,
   but padding lives outside that and still takes space, which is the
   sliver of text showing through before anything is unfolded. */
.feed-slide.expanded .feed-extra-inner {
  padding-top: 2px;
  padding-bottom: 6px;
}

.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; }

/* BB-LATEST-BANNER:START -- additive, phone-only, reversible. Matching
   block in index.html is tagged BB-LATEST-BANNER.

   The bottom line of the Latest feed answers two questions on one row:
   WHAT this selection is, and WHERE you are in it. The row floats over
   the poster and takes no layout height -- the same trick .dots-feed
   already used -- so the artwork still runs full bleed down to the tab
   bar. That is the whole reason the label lives here and not above the
   hero: there is no permanent top bar on either platform, and a band
   above a full-bleed poster breaks it.

   Label first, then the dots, in one flex row -- NOT the label at the
   left with the dots centred on the page. That was the first attempt and
   it overlapped, measured on the sandbox at 22px of collision:

     bar 480 | label needs 146 | dots 188 | 15 dots, not the 10 that
     the /latest endpoint's posts_per_page implied

   With grid 1fr auto 1fr the side tracks were 140px and the label needed
   146, and `overflow:hidden` did not save it -- that clips a box's
   CONTENT, not the box itself, so a justify-self:start item sized to
   max-content simply overflowed its track and sat on top of the dots.
   In a flex row they are laid out in sequence and cannot overlap by
   construction.

   Budget on a 390px phone: 14 padding + 146 label + 10 gap + 188 dots
   = 358. Narrower than that and the dots clip from the right, which is
   why they shrink and the label does not -- losing the fifteenth dot
   costs nothing, losing the word does.

   The small centred rule above this row is .feed-more-hint, one per feed
   slide. It is not part of this bar and stays centred. */
.feed-bar {
  position: absolute;
  left: 0; right: 0;
  bottom: 14px;
  z-index: 5;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 0 14px;
  /* The bar spans the full width. Without this it would sit over the
     bottom strip of the poster and swallow horizontal swipes there --
     the dots take their clicks back explicitly below. */
  pointer-events: none;
}
.feed-bar-label {
  flex: 0 0 auto;
  white-space: nowrap;
  padding: 4px 10px;
  border-radius: 999px;
  /* Dark fill, not a gold one. It matches the tab bar's own
     rgba(14,13,11,.97) chrome, and it is what makes the type legible
     over an arbitrary backdrop -- accent-coloured text alone cannot be
     relied on to read against a photograph. */
  background: rgba(14, 13, 11, 0.72);
  border: 1px solid var(--line);
  /* Identical to .strip-head, the desktop label for this same list, so
     both platforms say it the same way. */
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 2.2px;
  text-transform: uppercase;
  color: var(--accent);
}
.dots-feed {
  flex: 0 1 auto;
  min-width: 0;
  overflow: hidden;
  /* .dots centres its children; here they follow the label instead. */
  justify-content: flex-start;
  pointer-events: auto;
}
/* BB-LATEST-BANNER:END */

/* ---------------- 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 {
  text-align: left;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  min-width: 0;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 1.8px;
  text-transform: uppercase;
  color: var(--fg2);
}
.filter-val {
  margin-left: auto;
  white-space: nowrap;
  flex: 0 0 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);
}
/* The thumbnail and its Review / Suggestion strip, as one item: the row
   lays out the wrapper and the hover grows the wrapper, so the two never
   come apart. The strip is sized to the poster, never the other way round
   (width 0 + min-width 100% keeps its text out of the wrapper's width). */
.row-mk {
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  transition: transform 0.2s ease;
}
/* See .poster-card:hover — no lift where a tap would leave it stuck. */
@media (hover: hover) and (pointer: fine) {
  .row:hover .row-mk { transform: scale(1.08); }
}
/* A 50-60px poster has no room for the full-size label. */
.row-mk .mk-strip {
  width: 0;
  min-width: 100%;
  height: 16px;
  border-radius: 0 0 6px 6px;
  font-size: 8px;
  letter-spacing: 0.4px;
}
.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; }
/* ═════════ THE PAGE TITLE, AND WHY ONLY TWO PAGES HAVE ONE ═════════
   For a day there were six: Watchlist, Search, Collections, About, the
   Explore hub and the World Explorer, all sharing this rule. Bart:
   "the page headings are inconsistently placed. and just take up space,
   so remove page headings from Collections, Search, Watchlist, About
   (only World Explorer keeps it)".

   He is right about the cost. Those four pages announce themselves
   perfectly well — a search field, a hero, two lists, an essay — and a
   word above each of them bought nothing but 86px. The two that keep a
   title are the two that need one: the World Explorer, whose page is a
   map and would otherwise be unlabelled, and the reviewer workspace,
   which is a different application wearing the same shell.

   Neither of those is styled from here — both are lazy-loaded
   components whose own stylesheet is injected at runtime and therefore
   beats this file. bb-world.css and bb-write.css each restate these
   values, with a note pointing back. This rule survives for the Explore
   hub, which is built but unlinked (see EXPLORE_WAYS in app.js).

   THE PHONE GEOMETRY is the interesting half. The brand badge floats
   over the top-left of every view and unfolds into a ~203px pill on tap,
   so a title placed where the reel ENDS gets covered a moment later.
   Right-aligning it fixes the movement — the title's own edge is the
   window's — and padding-left reserves the pill's open width, which
   brandBadge measures and publishes as --bb-badge-open rather than
   anything guessing it here.

   nowrap is deliberate. At 26px "World Explorer" renders 158px against
   the 140 free beside the open pill; allowed to wrap it becomes two
   lines and the header grows past the 86px this arrangement saves, so
   instead it overhangs and the unfolded pill covers its first 18px for
   the 3.4s it stays open. Nothing moves either way, and a transient
   overlap costs less than a permanently taller header. */
.page-title {
  font-family: var(--font-display, inherit);
  font-size: clamp(26px, 2.6vw, 34px);
  font-weight: 700;
  letter-spacing: 0.2px;
  line-height: 1.1;
}

@media (max-width: 899px) {
  .page-title {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    /* The reel's own height, so the title is centred against it. */
    min-height: 52px;
    padding-left: calc(var(--bb-badge-open, 190px) + 4px);
    white-space: nowrap;
    text-align: right;
  }
  /* The hub's own top offset drops to the reel's, the title now being
     beside the reel rather than under it. */
  .exp-scroll { padding-top: calc(var(--sat) + 16px); }
}

/* The offset used to belong to .wl-head, a header whose only content was
   the word "Watchlist". Bart had that removed — "the page headings ...
   just take up space" — so the list carries its own clearance for the
   floating reel, the way Collections' hero and Search's field do. */
.wl-columns {
  flex: 1;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
  padding-top: calc(var(--topbar-h) + var(--sat) + 14px);
  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; }
.wl-list .row-mk .mk-strip,
.search-list .row-mk .mk-strip { border-radius: 0 0 8px 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); }

/* ---------------- explore (the hub) ----------------
   A short list of ways in, one row each: icon, name, a line saying what
   it does, a chevron. Rows rather than tiles because the list is going
   to grow and a row reads the same at three entries as at eight, while
   a tile grid has to be re-balanced every time one is added. Built by
   renderExplore() from EXPLORE_WAYS — see app.js. */
.exp-scroll {
  height: 100%;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
  padding: calc(var(--topbar-h) + var(--sat) + 14px) 20px 30px;
}
.exp-scroll::-webkit-scrollbar { display: none; }
.exp-head { margin-bottom: 18px; }
/* Type comes from .page-title — deliberately nothing here. */
.exp-sub { margin-top: 4px; font-size: 14px; color: var(--fg2); }
/* The title's own geometry is .page-title's, shared with every other
   page; only the line UNDER it needs saying here, and it needs nothing —
   it sits below the pill and takes the full column. */
@media (max-width: 899px) { .exp-head { text-align: right; } }
.exp-grid { display: flex; flex-direction: column; gap: 14px; }
/* EACH ROW SHOWS WHAT IT DOES. The first version put a 22px pictogram
   in a box beside each line of text and Bart's verdict was "boring" —
   fairly, because three identical grey boxes say nothing about three
   quite different ways in. Now the deal's own card backs, the turning
   globe and real posters out of the catalogue do the saying, and the
   card is built like the home page's CTAs: text left, artwork right,
   the artwork allowed to overrun the edge. */
.exp-card {
  position: relative;
  display: flex;
  align-items: center;
  gap: 14px;
  width: 100%;
  min-height: 118px;
  padding: 16px 18px;
  overflow: hidden;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: linear-gradient(135deg, rgba(201, 132, 58, 0.16), var(--card));
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.exp-card:hover { border-color: var(--accent); }
.exp-text { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1 1 auto; }
.exp-title { font-size: 18px; font-weight: 600; color: var(--fg); }
.exp-note { font-size: 13px; color: var(--fg2); line-height: 1.35; }
.exp-art {
  flex: 0 0 auto;
  position: relative;
  width: 116px;
  height: 96px;
  pointer-events: none;
}

/* --- the deal's three card backs ---
   The home page's fan, reused whole (.home-deal-fan / .home-deal-mini
   in the BB-HOME-EXTRAS block) rather than redrawn: same geometry, same
   reel, one implementation. Only scaled, and the spans it is built from
   here need blockifying — the home copy uses divs. */
.exp-art-deal .home-deal-fan {
  display: block;
  position: absolute;
  top: 50%; left: 50%;
  transform: translate(-50%, -50%) scale(0.72);
}
.exp-art-deal .home-deal-mini { display: flex; }

/* --- the turning globe --- */
.exp-art-globe .home-globe {
  position: absolute;
  top: 50%; left: 50%;
  width: 96px;
  height: 96px;
  transform: translate(-50%, -50%);
}

/* --- a handful of real posters ---
   Fanned and dimmed: enough to read as "the catalogue" without
   competing with the words beside them. The nearest is upright and
   fully lit, the ones behind lean away and fade, so the stack has a
   front. --i is set per image by exploreArtPosters. */
.exp-art-posters img {
  position: absolute;
  top: 50%;
  /* 62 wide plus three steps of 18 is exactly the 116 the art box has,
     so the back of the stack meets its left edge rather than being
     clipped by the card's overflow. */
  right: calc(var(--i) * 18px);
  width: 62px;
  height: 92px;
  object-fit: cover;
  border-radius: 5px;
  border: 1px solid rgba(232, 192, 101, 0.3);
  box-shadow: 0 6px 16px rgba(0, 0, 0, 0.5);
  transform: translateY(-50%) rotate(calc(var(--i) * 4deg));
  transform-origin: 50% 100%;
  /* Later in the mark-up means further back, so the stack is painted
     front to back by counting down. */
  z-index: calc(9 - var(--i));
  opacity: calc(1 - var(--i) * 0.22);
}
/* Wide: the rows stop growing rather than stretching a 44px icon and
   half a line of text across a metre of screen. Held to the width a
   line of prose is comfortable at, left-aligned under the heading —
   centring it would leave the heading floating off on its own. */
@media (min-width: 900px) {
  /* Only the SIDES change here. The top keeps the house sum from the
     base rule — on a desktop the badge is the rail's masthead, nothing
     is in the way, and this page lining up with Watchlist and Search
     matters more than reclaiming 72px on a screen that has it. */
  .exp-scroll { padding-left: clamp(24px, 3vw, 56px); padding-right: clamp(24px, 3vw, 56px); }
  /* Size comes from the shared clamp, which already reaches 30 here. */
  .exp-head, .exp-grid { max-width: 720px; }
  .exp-card { padding: 18px 18px; }
}

/* ---------------- 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; }
/* ---- the turn: a real two-faced card ---------------------------------
   REBUILT 21 Aug, on the same pattern as "Deal me three", after seven
   failed attempts to patch the old one.

   The old turn was a single panel rotated out to -90°, its CONTENT
   SWAPPED at the halfway point, then jumped to +90° and rotated back.
   Two things were wrong with that and only one of them was ever fixable
   by patching: the 179° jump could be interpolated by the browser
   instead of snapped (which showed the card's mirrored BACK face,
   carrying the old film — that is what a slow-motion film of it caught),
   and changing the content of a rotating layer asks the compositor to
   redraw it mid-turn.

   A real card flip does neither. Both faces exist BEFORE the turn
   starts, the wrapper rotates a continuous 180°, and nothing inside
   changes while it moves. There is no jump to interpolate and nothing to
   redraw. It is the same structure .deal-inner has used all along.

   .sur-flip is the thing that turns; the two .sur-panel faces ride on it,
   back-face hidden so only the one pointing at you is drawn. The outgoing
   face is a static CLONE of the panel taken at the moment of the tap —
   which is what avoids duplicating the panel, its ids and its bindings in
   the markup. */
#sur-flip {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  transition: transform var(--bb-turn, 1.1s) cubic-bezier(0.5, 0, 0.35, 1);
  will-change: transform;
}
/* Forward turns RIGHT-to-LEFT — the leading edge is the right-hand one,
   the same way a page turns and the same way the old two-halves flip
   went. Back is the mirror of it. */
#sur-flip.turning     { transform: rotateY(-180deg); }
#sur-flip.turning-back { transform: rotateY(180deg); }
#sur-flip.no-fx       { transition: none; }

.sur-panel {
  position: absolute;
  inset: 0;
  isolation: isolate;
  overflow: hidden;
  /* Only the face pointing at the reader is drawn. Without this the
     mirrored reverse of the other one shows through. */
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
}
/* The incoming face waits at 180°, so when the wrapper reaches 180° it is
   the right way up. Net zero — which is why the reset at the end is a
   silent no-op rather than a visible jump. */
.sur-panel.sur-away  { transform: rotateY(-180deg); }
.sur-panel.sur-away-r { transform: rotateY(180deg); }
/* The outgoing snapshot. Inert: no animation, and no clicks — its markup
   still carries the classes the delegated handlers listen for, so without
   this a tap during the turn could act on the film being replaced. It
   exists for the length of one turn and nothing else. */
.sur-ghost, .sur-ghost * { animation: none !important; }
.sur-ghost { pointer-events: none; }
#surprise-result[hidden] { display: none; }
/* Invisible by default: the poster's wrapper only becomes a real box in
   the wide layout (see BB-LAPTOP). Everywhere else its children are laid
   out as though it were not there. */
.sur-art { display: contents; }
.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; }
.sur-panel::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. */
.sur-panel.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. */
.sur-panel.opening .feed-extra { transition-duration: 0.95s; }
.sur-panel.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-panel .sur-extra .btn-primary { 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 — the small-caps "ABOUT" kicker — removed 7 Sep 2026 with
   its mark-up: the page has a real page title now and the two said the
   same word in two idioms. Bart: "add a consistent one to About (so
   remove the other About heading on that page)". */
.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 ---------------- */
/* OPAQUE. #view-home is the only view with no `hidden` attribute in the
   markup, so it is painted the moment the shell parses — and this screen
   is absolutely positioned over it. Transparent, that meant the homepage
   showed THROUGH the boot spinner for the whole of boot, and vanished
   only when applyRoute() finally put the right view up. On a deep link
   that reads as the homepage flashing before the page you asked for.
   Most visible on /about/, where what replaces it is another tab rather
   than an opaque overlay, but a review deep link did it too. */
/* OPAQUE, and with a STACKING CONTEXT of its own. Both are needed and
   neither is enough alone.
   #view-home is the only view with no `hidden` attribute in the markup, so
   it is painted the moment the shell parses, and this screen sits over it.
   Transparent, the homepage showed straight through the spinner. Opaque
   but at z-index:auto, its z-indexed CHILDREN still did — .feed-actions is
   z-index:3, the feed arrows and the discover panel carry their own — and
   because .view sets no z-index there is no stacking context to keep them
   inside their own view. So the boot screen covered the background and the
   furniture floated over it: arrows, bookmark, "From the catalogue".
   20 puts it above anything inside a view and below the tab bar, the
   masthead (25) and the overlays (30), which should all stay visible. */
#view-boot {
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--bg);
  z-index: 20;
}
#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); }
/* The breakpoint-swapped pair (see the tab bar in index.html). Written
   as display:none rather than [hidden] so the gate can un-hide BOTH
   without either appearing at the wrong width — the media query has the
   last word. The phone half shows below 900. */
.tab-wide { display: none; }
@media (min-width: 900px) {
  .tab-phone { display: none; }
  .tab-wide:not([hidden]) { display: flex; }
}

/* ---------------- the staff entry ----------------
   The workspace is not a seventh place to read; it is the door out of the
   reading app and into the writing one, and the rail should say so before
   it is clicked. Hence the rule and the gap rather than a seventh
   identical row.

   It is desktop-only on purpose. The phone bar divides its width six ways
   already, a seventh would squeeze all seven, and the editor it leads to
   is a two-column tool that a phone cannot usefully show. A reviewer on a
   phone still reaches /#/write by typing it. */
.tab-staff { display: none; }

/* ---------------- 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);
  /* Taller than the 235px strip it used to be, and sized from the window
     rather than fixed: a review page is a landing page now — shared links
     open here — and the backdrop is the first thing anyone sees. The
     clamp keeps a short phone from giving so much away to the picture
     that the title falls below the fold, and stops a tall one from
     turning the hero into the whole screen. */
  height: clamp(235px, 38vh, 330px);
  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; }
/* ---- the review page on a phone -------------------------------------
   It is the landing page for every shared link, so it is the one overlay
   that has to carry the mark: the reel sits in the top-left corner, the
   same as on Latest, Surprise and everywhere else, and unfolds into the
   wordmark on a tap. That corner is then spoken for, so the hero's
   circular Back button stands down and a chevron takes its place at the
   head of the footer row, beside Trailer and Stills.

   The masthead is raised only while the REVIEW page is open. Person and
   list pages keep their own back button in that corner and must stay
   above it. */
body.bb-review-open .app-topbar { z-index: 35; }
#ov-back { display: none; }
/* Three classes because .ov-footer .btn-outline sets flex and padding
   further down the file and would otherwise win on source order. */
.ov-footer .btn-outline.ov-back-foot {
  flex: 0 0 auto;
  padding: 10px 14px;
  align-items: center;
  justify-content: center;
}
.ov-back-foot 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;
  /* Just enough tuck for the gradient's dissolve to carry the join —
     the title now starts at the FOOT of the picture rather than 44px up
     inside it, which is where the air between the reel and the title
     came from. */
  margin-top: -12px;
  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); }

/* ---- why the poster is on the card's back as well --------------------
   THE EMPTY CARD. An image the browser has never PAINTED has no decoded
   bitmap, and .deal-front is rotated away behind backface-visibility:
   hidden — so from the deal until the turn it is never painted, its
   poster is never decoded, and asking the card to show it means asking
   for a decode in the same frame as a 1.1s rotation. Cold, that lost:
   the card turned onto nothing. Warm, the decode was already in memory
   from an earlier deal and nobody saw a thing, which is exactly why it
   came right "once you keep dealing" — and why the FIRST card turned was
   always the worst, the last the least.

   `complete` never showed this, because the file had arrived. The decode
   had not.

   So each card carries a transparent copy of its own poster on the face
   that IS on screen. Transparent-but-rendered is decoded; hidden is not.
   It is the same standby-buffer trick the Surprise page uses, for the
   same reason, and it costs one extra element per card. */
.deal-back .deal-pre {
  position: absolute;
  left: 0; top: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  object-position: 50% 0;
  opacity: 0;
  pointer-events: none;
}

/* 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. */
/* The picture covers the whole face, so what is UNDER it is invisible —
   right up until the moment there is no picture, when `background: none`
   meant the card back became a hole you could see the page through. A
   solid card underneath costs nothing and means the worst case is a dark
   card rather than no card. */
.deal-back.deal-art {
  display: block;
  background: var(--card);
}
.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. Through a variable
     so ?dealslow=1 can stretch it for filming without changing anything
     else — the Surprise flip was only ever solved that way. */
  transition: transform var(--bb-deal-turn, 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;
  /* Both faces get a layer of their own the moment the hand is dealt,
     rather than whenever the compositor gets round to it. The turn then
     has nothing left to promote or rasterise while it is running — see
     the note beside the reel removal in app.js for what that cost. */
  will-change: transform;
  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);
  /* The poster is painted here as well as in the <img> above it — see the
     note in app.js. Laid out to match that element exactly: full width,
     own aspect, anchored to the top. */
  background-size: 100% auto;
  background-position: 50% 0;
  background-repeat: no-repeat;
}

/* 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; }
/* The sharp copy, laid OVER the catalogue thumbnail rather than replacing
   it — see the note in app.js where it is built. Same layout rules, so
   the two sit exactly on top of one another and the reveal is a change of
   resolution and nothing else. Absolute, so it does not add a second
   picture's worth of height to the card. */
.deal-front .deal-hi {
  position: absolute;
  left: 0; top: 0;
  width: 100%; height: auto; min-height: 100%;
  display: block;
  opacity: 0;
}
.deal-front .deal-hi.ready { opacity: 1; }
.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 band 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%). */
/* --kb-max is set per hero by applyFaceFraming, from the reviewer's
   authored end frame — the box they pulled in over the picture. 1.165 is
   the depth used when nobody has said otherwise, and the two must stay
   the same number: see the `hs` default in applyFaceFraming. */
@keyframes bb-hero-zoom {
  from { transform: scale(1); }
  to { transform: scale(var(--kb-max, 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) {
  #sur-flip { transition: none; }
  .feed-arrow { animation: none; }
  .popup-card { animation: none; }
  .deal-inner { transition: none; }
}

/* The portrait and "A Review by NAME:" since 5.13.0 — the same .sug-who
   row the Discovery Pick card uses, so type and size match by construction.
   The italic 13px grey caption it used to be is gone. */
.ov-byline {
  margin-top: 15px;
  color: var(--fg);
}
/* 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-review[hidden], #ov-verdict-label[hidden] { display: none; }

/* ============ THE SUGGESTION PAGE (5.11.0) ============
   The review overlay in a different shape — see fillSuggestion() in
   app.js. Hidden here, because they are the review's: the Sneak Peek in
   the side column and the footer's Trailer / Stills — the peek is inline
   on this page. (Your Take stays: a Discovery Pick has a rating to agree or
   disagree with.) */
#review-overlay.is-suggestion #ov-actions-label,
#review-overlay.is-suggestion #ov-actions-side,
#review-overlay.is-suggestion #ov-trailer,
#review-overlay.is-suggestion #ov-stills { display: none !important; }
.ov-sug {
  align-self: stretch;
  display: flex;
  flex-direction: column;
  min-width: 0;
}
.ov-sug[hidden], .sug-sec[hidden], .sug-pitch[hidden] { display: none; }
.sug-card {
  margin-top: 6px;
  padding: 18px 20px 16px;
  border: 1px solid var(--accent);
  border-radius: 14px;
  background: rgba(201, 132, 58, 0.05);
}
.sug-who { display: flex; align-items: center; gap: 16px; }
.ov-byline.sug-who[hidden] { display: none; }
/* Two reviewers on one review: their portraits overlap a little. */
.sug-avas { display: flex; flex: 0 0 auto; }
.sug-avas .sug-ava + .sug-ava { margin-left: -14px; }
.sug-ava {
  flex: 0 0 68px;
  width: 68px;
  height: 68px;
  border-radius: 50%;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--card);
  box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent);
  font: 600 26px/1 var(--font-display);
  color: var(--accent);
}
.sug-ava img { width: 100%; height: 100%; object-fit: cover; }
.sug-who-main { min-width: 0; }
.sug-label {
  min-width: 0;
  font: 600 19px/1.25 var(--font-display);
  letter-spacing: 0.3px;
  color: var(--fg);
}
.sug-label .byline-link { text-decoration: none; }
.sug-label .byline-link:hover { text-decoration: underline; }
.sug-pitch {
  margin-top: 14px;
  font-size: 17px;
  line-height: 1.6;
  color: var(--fg);
}
.sug-note {
  margin-top: 12px;
  font-size: 12.5px;
  font-style: italic;
  color: var(--fg2);
}
.sug-sec { margin-top: 26px; }
.sug-sec > .ov-label { margin-top: 0; }
.sug-peek { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; }
.sug-trailer,
.sug-still {
  position: relative;
  display: block;
  min-width: 0;
  min-height: 0;
  padding: 0;
  border: 0;
  border-radius: 10px;
  overflow: hidden;
  background: var(--card);
  cursor: pointer;
}
.sug-trailer { aspect-ratio: 16 / 9; }
.sug-trailer img,
.sug-still img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform 0.25s ease; }
.sug-trailer:hover img,
/* See .poster-card:hover. */
@media (hover: hover) and (pointer: fine) {
  .sug-still:hover img { transform: scale(1.04); }
}
.sug-play {
  position: absolute;
  inset: 0;
  margin: auto;
  width: 58px;
  height: 58px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(14, 13, 11, 0.72);
  box-shadow: 0 0 0 2px var(--accent);
}
.sug-play svg { width: 24px; height: 24px; margin-left: 3px; fill: var(--accent); }
/* Always a 2x2, whatever the count, and the same 16:9 as the trailer
   beside it so the two line up top and bottom. */
.sug-stills {
  display: grid;
  grid-template-columns: 1fr 1fr;
  grid-template-rows: 1fr 1fr;
  gap: 6px;
  aspect-ratio: 16 / 9;
}
.sug-stills .sug-still { border-radius: 8px; }
.sug-more {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(14, 13, 11, 0.62);
  font: 600 15px/1 var(--font-display);
  letter-spacing: 0.6px;
  color: var(--fg);
}
.sug-story { font-size: 16px; line-height: 1.7; color: var(--fg); }
.sug-src { margin-top: 8px; font-size: 12px; color: var(--fg2); }
@media (min-width: 600px) {
  .sug-peek { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
}
.ov-cast {
  display: flex;
  gap: 14px;
  width: 100%;
  overflow-x: auto;
  overflow-y: hidden;
  touch-action: auto;           /* the overflow is the fix — see .hero-track */
  padding: 6px 0 4px;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
}
.ov-cast::-webkit-scrollbar { display: none; }
/* Locations (5.12.0): "Shot in:" and "Set in:", each place a link to the
   World Explorer where it is available. */
.ov-locs { width: 100%; font-size: 14px; line-height: 1.55; color: var(--fg); }
.ov-locs[hidden], #ov-loc-label[hidden] { display: none; }
.ov-loc-line + .ov-loc-line { margin-top: 6px; }
.ov-loc-k { color: var(--fg2); }
a.ov-loc { color: var(--accent); text-decoration: none; }
a.ov-loc:hover { text-decoration: underline; }
.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; }
/* ═══════════ THE CREW BLOCK (5.17.3) ═══════════
   Director, cinematographer and composer under ONE heading, each saying
   which it is with an icon and a word instead of a band of its own —
   three bands for three single names was most of what this block cost in
   a 256px column. On a phone they sit in one row, which is what Bart
   asked for: "on mobile they could be in a single row (Director,
   Cinematographer, Composer)".

   SIZED AND WRAPPED EXACTLY LIKE THE CAST, on Bart's instruction. These
   were briefly cards that grew to fit their longest line, which kept
   "Cinematographer" on one line and stopped "Jim Jarmusch" wrapping —
   and made a row of different widths sitting directly above a row of
   identical ones, which is worse than either. So nothing here sets a
   width: the card is .cast-card and takes the cast's 78px, or 92px on
   the wide layout, and a long name wraps there exactly as it does below. */
.crew-role svg {
  width: 11px; height: 11px;
  /* Inline rather than a flex child, so the role wraps as ordinary text
     with the icon leading it — a flex row would keep the icon on its own
     line and push the word under it. */
  vertical-align: -1px;
  margin-right: 3px;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
  fill: none;
}
/* The break that gives the director a line of his own on the wide
   layout — see the note in the 900px band. Nothing on a phone, where the
   three are meant to share one row. */
.crew-break { display: none; }
.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); }
/* The Follow row is a set of plain anchors so links behave like links;
   they need the button's own reset, which they do not inherit. */
a.share-target { text-decoration: none; }
/* The sheet now holds two headed blocks. The second heading follows a
   row of icons and needs to read as a new section rather than a caption
   under the one above it. */
.share-targets + .share-head { margin-top: 6px; }
.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; }

/* A curated collection's name and its one-line editor's note. Inside
   .rail-head, so both share the heading's left edge: stacked on a phone,
   one line with a dash between them from 900px (see that block). */
.rail-title { min-width: 0; display: flex; flex-direction: column; gap: 5px; }
/* The note can run to two lines on a phone; See all stays on one. */
.rail-head-collection .see-all { flex: 0 0 auto; white-space: nowrap; margin-left: 14px; }
.rail-note {
  margin: 0;
  font-style: italic;
  font-size: 13px;
  line-height: 1.35;
  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; }

/* ---------------- the REVIEWER page ----------------
   The same overlay serves actors and reviewers. An actor gets the centred
   portrait it always had; a reviewer gets a person and a piece of writing
   side by side, because the bio is the point of their page and centred
   text under a circle is where bios go to be skipped. */
.pv-author .pv-head {
  display: flex;
  align-items: flex-start;
  gap: 18px;
  text-align: left;
  padding-top: calc(var(--sat) + 58px);
}
.pv-author .pv-ava { margin: 0; flex: 0 0 auto; }
.pv-headmain { min-width: 0; }
.pv-author .pv-name { margin-top: 0; }
/* The pill and the count belong UNDER THE NAME, on its left edge. The
   actor page centres them because everything there is centred; inheriting
   that here left them floating in the middle of a left-aligned column,
   attached to nothing. */
.pv-author .pv-meta { justify-content: flex-start; }

/* ---- THE REEL AS THE AVATAR — NARROW LAYOUT ONLY ----

   On a phone the corner badge behaved two different ways on this one
   page, and both were wrong. .app-topbar sits at z-index 25 and
   .person-overlay at 31, so arriving from About put the overlay OVER the
   badge and the reel vanished; but body.bb-review-open lifts the topbar
   to 35, so arriving from a review left it on top, overlapping the
   portrait. One page, two appearances, decided by where you came from.

   So on a phone there is ONE reel and it is the avatar: turning while
   there is nothing else to show, fading aside when a photograph arrives.

   None of this applies to the wide layout, where the badge lives in the
   rail, is never overlapped by anything, and the avatar is a portrait as
   it always was. */
@media (max-width: 899px) {
  body.bb-author-open .app-topbar { display: none; }

  .pv-author .pv-ava {
    position: relative;
    overflow: hidden;
    /* THE LOGO'S RING, not a flat outline: the gradient paints the
       border box and the near-black paints the padding box, so the rim
       reads as a hairline of light. Same two gradients and the same
       shadow as .brand-badge — one ring, described once, worn twice. */
    /* 3px, not the badge's 1.5: the ring is drawn at nearly twice the
       diameter here, and a hairline that reads as a rim at 52px reads as
       a scratch at 98. Same gradients, scaled weight. */
    border: 3px solid transparent;
    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);
  }
  /* Inset so the ring sits AROUND the reel rather than clipping it —
     the badge holds a 44px reel in a 52px pill, and this is that same
     proportion at 98px. */
  .pv-author .pv-ava .pv-reel {
    position: absolute;
    inset: 7px;
    display: block;
    /* Slow, and eased at both ends: this is a reveal, not a state
       change, and 0.55s made it read as a swap. */
    transition: opacity 1.6s cubic-bezier(0.4, 0, 0.2, 1);
  }
  /* The app's own keyframes at its own speed — 4.5s, the same turn the
     header makes. Not a second animation and not a second tempo. */
  .pv-author .pv-ava .pv-reel .reel-spin {
    display: block;
    width: 100%; height: 100%;
    animation: bb-reel-spin 4.5s linear infinite;
  }
  .pv-author .pv-ava .pv-reel svg { display: block; width: 100%; height: 100%; }
  .pv-author .pv-ava img {
    position: absolute;
    inset: 0;
    width: 100%; height: 100%;
    object-fit: cover;
    opacity: 0;
    transition: opacity 1.6s cubic-bezier(0.4, 0, 0.2, 1);
  }
  .pv-author .pv-ava.has-photo img { opacity: 1; }
  .pv-author .pv-ava.has-photo .pv-reel { opacity: 0; }
  /* IT KEEPS TURNING WHILE IT GOES. Stopping the animation here was the
     "snap": animation:none drops the element back to its untransformed
     state, so the B jumped upright mid-revolution and only then began to
     fade. It now fades out still turning, and the animation costs
     nothing once the element is invisible. */
}

/* The wide layout: no reel in the circle, the photograph straight away,
   and NO INITIAL LETTER. The letter only ever appeared for the half
   second before the picture arrived — a big glyph that flashed up and
   vanished, which reads as a fault rather than a placeholder. There is
   always a picture in the end here (a gravatar at worst), so the circle
   simply waits, empty and quiet. */
@media (min-width: 900px) {
  .pv-author .pv-ava .pv-reel { display: none; }
  .pv-author .pv-ava img { opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
  .pv-author .pv-ava .pv-reel .reel-spin { animation: none; }
  .pv-author .pv-ava .pv-reel,
  .pv-author .pv-ava img { transition: none; }
}

/* NO BACK BUTTON on a reviewer's page — the browser's own Back does
   this, and on a phone it shared the top-left corner with the brand
   reel. (The corner reel is gone from this page entirely now; see the
   avatar block above.) The head keeps its top padding: that space holds
   the portrait clear of the top of the screen. */
.pv-author .pv-head .ov-back { display: none; }
.pv-bio {
  margin: 10px 0 0;
  font-size: 14px;
  line-height: 1.65;
  color: var(--fg2);
  max-width: 62ch;
}

.pv-search { padding: 2px 18px 10px; }
.pv-q {
  width: 100%;
  font: inherit;
  font-size: 15px;
  color: var(--fg);
  background: var(--bg2);
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: 11px 16px;
}
.pv-q::placeholder { color: var(--dim); }
.pv-q:focus { outline: none; border-color: var(--accent); }

.pv-sort { display: flex; gap: 8px; padding: 0 18px 8px; flex-wrap: wrap; }
/* Reviews / Discovery Picks / Curated Collections (5.13.0) — a segmented row
   above the sort buttons, in the accent when chosen. */
.pv-kind { display: flex; gap: 8px; padding: 2px 18px 12px; flex-wrap: wrap; }
.pv-kind[hidden] { display: none; }
.pv-kind-btn {
  font: 600 13px/1 var(--font-display);
  letter-spacing: 1.2px;
  text-transform: uppercase;
  color: var(--fg2);
  background: transparent;
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: 9px 16px;
  cursor: pointer;
}
.pv-kind-btn .n { margin-left: 4px; opacity: 0.6; }
.pv-kind-btn:hover { color: var(--fg); border-color: var(--fg2); }
.pv-kind-btn.on { color: var(--accent-ink, #0e0d0b); background: var(--accent); border-color: var(--accent); }
.pv-kind-btn.on .n { opacity: 0.75; }
/* Curated Collections replace the list with rails, which need the whole
   width rather than the reviewer page's two-column grid. */
.pv-films.pv-colls { display: block !important; padding-left: 0; padding-right: 0; }
.pv-films.pv-colls .rail-head { padding-left: 18px; padding-right: 18px; }
.pv-films.pv-colls .rail { padding-left: 18px; padding-right: 18px; }
.pv-sort-btn {
  font: inherit;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.4px;
  color: var(--fg2);
  background: transparent;
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: 6px 14px;
  cursor: pointer;
  /* The label flips between "Latest" and "Oldest", "A–Z" and "Z–A", so
     the row would jog sideways on every press. A floor wide enough for
     the longest of each pair holds the buttons still. */
  min-width: 82px;
  text-align: center;
}
.pv-sort-btn:hover { color: var(--fg); border-color: var(--fg2); }
.pv-sort-btn.on { color: var(--accent); border-color: var(--accent); }

/* ============================================================
   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 */
/* geometry now lives on .sur-panel (see the two-faced turn above) */
/* 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: 900px) {
  /* 220, not 184. Bart: "the entire left bar in desktop and laptop mode
     can be a bit wider and the menu (font sizes) and logo a bit larger to
     help with readability and navigation." Everything that has to know
     how wide the rail is reads this — including, now, the Surprise
     poster's height bound, which used to have 184 baked into a
     hand-multiplied constant. */
  :root { --rail: 220px; }

  #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: 196px 14px 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: 14px;
    padding: 13px 14px;
    width: 100%;
    border-radius: 8px;
  }
  .tab:hover { background: rgba(255, 255, 255, 0.04); }

  /* The separator is drawn on the item rather than as an element of its
     own, so nothing exists in the DOM while the item is hidden — a rule
     floating under About with no menu item beneath it is worse than no
     rule at all. */
  .tab-staff {
    display: flex;
    margin-top: 22px;
    padding-top: 20px;
    position: relative;
    color: var(--dim);
  }
  .tab-staff::before {
    content: "";
    position: absolute;
    top: 0; left: 12px; right: 12px;
    border-top: 1px solid var(--line);
  }
  .tab-staff span { color: var(--dim); }
  .tab-staff.active span { color: inherit; }
  .tab span { font-size: 13.5px; letter-spacing: 1.2px; 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: 78px;
    height: 78px;
    margin: 0;
    padding: 5px;
    border: 1.8px 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: 20px; }
  .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: 11.5px; }

  .view,
  .overlay { left: var(--rail); bottom: 0; }
  /* .lv-head's back button is position:FIXED, so left:14px means the
     LEFT EDGE OF THE WINDOW — which on the wide layout is the middle of
     the tab rail, where it sat on top of the logo. Offset it past the
     rail so it lands inside the overlay it belongs to.

     ONLY that one. The review page's #ov-back is position:absolute
     inside .ov-hero, which already starts at the rail's edge, so giving
     it the same offset moved it a second rail's width into the middle of
     the picture. Two buttons that look alike are not two buttons
     positioned alike. */
  .lv-head .ov-back { left: calc(var(--rail) + 14px); }
  /* The hero's circle is the wide layout's Back; the footer chevron is
     the phone's. Exactly one of them at a time. */
  #ov-back { display: flex; }
  .ov-back-foot { display: none; }

  /* ---------------- 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 {
    /* FLEX, not grid — and the reason matters, because the grid version
       shipped a real regression.

       The poster's WIDTH comes from its HEIGHT (height:100%, width:auto,
       aspect preserved). Put that in a grid column sized `auto` and the
       track's width depends on the poster while the poster's width depends
       on the row's height, which depends on the track: a circular
       dependency the browser settles by ratcheting the track down on each
       pass. Observed exactly as reported — the poster got smaller on every
       resize and never grew back.

       Flex has no such loop: the item is laid out from its own height, and
       the row simply gives it the width it asks for. Verified across three
       forced reflows (stable) and across a height sweep — poster 420 -> 714
       -> 1011 as the band grew, ratio held at 1.50, and it returned to 420
       when the band shrank again. */
    display: flex;
    align-items: center;
    justify-content: flex-start;
    height: 100%;
    /* The one fixed distance between the poster and the words. With the
       track gone, this is the whole of the space between them — it used to
       be this plus 180px of empty column. */
    gap: clamp(32px, 3.5vw, 56px);
    /* Right padding clears the next-slide arrow. The columns shrink
       proportionally as the window narrows, so while there is 460px of
       clearance at 1900, around 1200 the text's right edge crosses the
       arrow and the last line runs underneath it. The arrow occupies the
       46px ending 18px from the edge; 92 keeps text 28px clear of it at
       every width. Left padding unchanged — it is what lines the poster up
       with the strip and the panel below. */
    padding: 48px 92px 20px clamp(24px, 3vw, 56px);
  }
  /* 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. */
  /* The slide takes the height the flex parent gives it, so the grid row
     below is a DEFINITE height rather than one derived from its content.
     That is what lets the poster be measured against the space available
     instead of the other way round. */
  .feed-slide { height: 100%; }
  .feed-art {
    /* The frame HUGS THE POSTER. It used to be width:100% of the column,
       which was fine while the image filled it — but once the image was
       allowed to shrink to fit the row, the frame stayed full width and
       .feed-blur showed through either side as grey bars. Width from the
       content, so the border-radius and the shadow stay on the picture. */
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: 0;
    position: relative;
    /* Grows never; shrinks only as a LAST RESORT — see the floor on
       .feed-text below. A portrait never reaches that point, so this is
       not the old width cap coming back: it is the guard that stops a
       picture of the wrong shape from taking the whole row and leaving
       the title and the button at zero width. */
    flex: 0 1 auto;
    min-width: 0;
    /* THE FRAME CARRIES THE PICTURE'S OWN SHAPE.
       "height:100%; width:auto" was nearly right and failed in exactly
       one case: this is a flex item with `flex: 0 1 auto`, so when the
       row is tight the text floor SHRINKS it — and a frame whose width
       has been shrunk while its height is still 100% is a different
       shape from the picture, which object-fit:contain then letterboxes
       inside. Measured at 1300x1180: frame 374x821 (0.456) against a
       natural 0.701, i.e. 144px of bar above and below. Bart: "poster
       still not fitting in the window on latest (bars above and below)".
       (The same mechanism, from a max-width instead of a shrink, put a
       bar there once before. Any constraint on ONE side of a box whose
       other side is fixed will do it.)

       Giving the box the ratio and bounding BOTH sides lets it fit
       whichever way it has to and stay the picture's shape either way.
       The ratio comes from the file, published by fitArtFrames() in
       app.js; the 2/3 fallback is what almost every poster is, so a
       measurement that never arrives degrades to today's behaviour
       rather than to a collapsed box. */
    aspect-ratio: var(--bb-art-ar, 2 / 3);
    height: auto;
    width: auto;
    max-height: 100%;
    max-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;
    /* THE POSTER MUST NEVER BE CROPPED. It was, and here is the exact
       mechanism, measured at 1055x1129: the image was width:100% with
       height:auto, so a 780x1112 poster in a 375px column asked for 535px
       — while the grid row, sharing a viewport that does not scroll with
       the strip (294) and the discover panel (321), could only give it
       446. .feed-art is overflow:hidden, so the bottom 90px was simply
       cut off. object-fit:contain was already here and could do nothing,
       because an element whose own height is auto never has a box that
       differs from its intrinsic ratio for object-fit to reconcile.
       Bounding the HEIGHT is what makes it engage: the poster now scales
       down to whatever the row allows and stays whole.

       FILLS THE FRAME, both ways. It used to be `height:100%; width:auto`,
       which was the right pairing while .feed-art was `height:100%`. Now
       the frame is the one carrying the ratio and BOTH its sides are
       auto — so `height:100%` here resolved against an auto height, i.e.
       against nothing, and the frame in turn sized itself from this
       image: each waiting on the other, both settling at zero, and the
       hero poster vanished from the page entirely (4.88.8; the frame
       half of that change shipped without this half). The frame is now
       exactly the picture's shape, so filling it is not a crop —
       object-fit:contain has nothing left to reconcile and stays only as
       a guard for the moment before the ratio is published. */
    height: 100%;
    width: 100%;
    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;
    /* Takes whatever the poster leaves. Its right edge is bounded by the
       slide's 92px right padding, which is what keeps it clear of the
       next-slide arrow — the arrow sits a fixed distance from the window
       edge, so that padding IS the text column's maximum. */
    /* THE FLOOR: what it never gives up (shrink 0), before growing into
       whatever is left, still bounded by the 62ch below. It exists for
       the landscape-artwork case, which used to leave nothing to read.

       240, not 320. That comment used to say "a portrait poster and
       320px of text fit side by side at every window this layout runs
       at" — true when the layout began at 1024 with a 184px rail and
       nothing else in the hero's row. It now begins at 900 with a 220px
       rail AND an explore column beside it, so at 900 the hero row is
       about 450px wide and a 320px floor left the poster ~90. Lowering
       the floor lets the narrow end degrade to a small poster instead of
       a broken one. If the hero still feels tight down there, this
       number and --bb-panel are the two to turn. */
    flex: 1 0 clamp(240px, 34%, 560px);
    min-width: 0;
    max-width: 62ch;
    max-height: 60vh;
    overflow-y: auto;
    padding: 0;
    /* `background: none` was here, to strip the phone's shading off a
       column that had nothing behind it. The words sit over the picture
       again on this layout (see the artwork-panel block at the end of
       this file), so the shared .feed-text/.sur-body shading applies
       here too and turning it off would have to be undone twice over. */
  }
  /* 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. */
  /* The bookmark lives INSIDE the poster's frame on this layout — app.js
     moves the one button there (see positionFeedActions). .feed-art is
     position:relative, so this is simply its corner: no measurement, and
     nothing to recompute when the feed scrolls or a poster turns out to be
     a different shape. The base rule's top uses the phone's safe-area
     inset, which means nothing here. */
  .feed-art > .feed-actions {
    top: 14px;
    right: 14px;
    left: auto;
  }
  .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); }
  /* 40 = the slide's 32px of padding (which is where the card's line now
     is) plus 8 of air, so an arrow sits inside the card rather than on
     its border. Moves with .feed-slide's padding above. */
  .feed-arrow-l { left: 40px; }
  .feed-arrow-r { right: 40px; }

  /* 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);
  }
  /* The strip is sized by its content and the hero takes whatever is
     left (#feed is flex:1), so every pixel taken off a strip card is a
     pixel given to the hero poster. Measured at 1280x1129: the strip was
     338px against the hero's 416, which is a lot of the page spent on
     thumbnails of the same films the hero is already showing. Cards down
     from 168 to 144 wide and the padding tightened gives the hero about
     fifty back — the whole card scaled rather than the picture cropped,
     so the posters keep their proportions. */
  .feed-strip {
    flex: 0 0 auto;
    display: flex;
    gap: 14px;
    overflow-x: auto;
    overflow-y: hidden;
    touch-action: auto;         /* the overflow is the fix — see .hero-track */
    scrollbar-width: none;
    padding: 12px clamp(24px, 3vw, 56px) 14px;
    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 144px;
    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); }
  /* THE CURRENT FILM STANDS PROUD, exactly as a hovered one does —
     Bart asked for "the hover-over effect on the poster of the
     selected/shown hero". Same transform, so the two never drift apart;
     the row already leaves headroom for it, because hover needed the
     same. */
  .feed-strip button.on { opacity: 1; transform: translateY(-6px) scale(1.06); }
  .feed-strip img {
    width: 144px; height: 207px;
    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 */
/* BB-LATEST-BANNER: and with the dots gone there is no row left to sit
   on, so the whole floating bar goes too. The desktop label for this
   list is .strip-head, which is a separate decision and still off. */
.feed-bar { display: none; }

  /* 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. */
  /* SCOPED TO THE REVIEW OVERLAY, and it has to be: #list-overlay reuses
     .ov-scroll for its own scroller, so an unscoped rule turned every
     list view — a genre, Based on Your Bookmarks, a curated
     collection — into the same two-column grid. The heading took column
     one and the ENTIRE LIST was crammed into the 300px column two, with
     a screen-wide gap down the middle. It looked like "See all" was
     broken; it had opened perfectly and been laid out into a gutter.
     The class went from an opt-in experiment to the default without this
     selector being narrowed to match. */
  .bb-layout-aligned #review-overlay .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-crew-label,
  .bb-layout-aligned .ov-body > .ov-side > #ov-cast-label,
  .bb-layout-aligned .ov-body > .ov-side > #ov-loc-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; }
  /* THE CREW BLOCK, TWO LINES DEEP. Bart asked for the cinematographer
     and composer side by side to save the column's height, and for the
     director to keep the width his portrait deserves — so the director's
     card takes a line of its own and the other two share the one below.
     Wrapping does it: nothing has to know how many cards there are, and
     a film with no crew fetched still shows its director correctly on the
     one line it has. */
  /* THE DIRECTOR TAKES A LINE OF HIS OWN, with the cinematographer and
     composer side by side below — the arrangement Bart picked. The line
     is made by a zero-height full-width break after his card rather than
     by widening the card itself: every card here has to stay the cast's
     92px, so the one that starts the second line cannot be the one that
     ends the first. */
  .bb-layout-aligned .ov-side #ov-crew { flex-wrap: wrap; row-gap: 12px; }
  .bb-layout-aligned .ov-side #ov-crew .crew-break {
    display: block;
    flex: 0 0 100%;
    height: 0;
  }
  .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-crew-label { order: 3; }
  .bb-layout-aligned #ov-crew { order: 4; }
  .bb-layout-aligned #ov-cast-label { order: 5; }
  .bb-layout-aligned #ov-cast { order: 6; }
  /* After the cast, before Your Take — equal order keeps DOM order. */
  .bb-layout-aligned #ov-loc-label,
  .bb-layout-aligned #ov-locs { order: 6; }
  .bb-layout-aligned .ov-side .ov-locs { padding: 2px 0 4px; }
  .bb-layout-aligned #ov-react { order: 7; }
  .bb-layout-aligned .ov-share-label { order: 8; }
  .bb-layout-aligned .ov-share-inline { order: 9; }
  /* FOLLOW AND SHARE ARE TWO BLOCKS, and equal order numbers would not
     keep them apart: order groups before DOM order, so both headings
     would collect at 8 and both rows at 9 — "Follow", "Share", then two
     unlabelled rows of icons. Numbered by id, which outranks the class
     rules above it. */
  .bb-layout-aligned #ov-follow-label  { order: 8; }
  .bb-layout-aligned #ov-follow-inline { order: 9; }
  .bb-layout-aligned #ov-share-label   { order: 10; }
  .bb-layout-aligned #ov-share-inline  { order: 11; }
  .bb-layout-aligned #ov-watch { order: 12; }
  .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); }
  /* padding-top: see the note on .wl-columns in this block — the reel is
     the rail's masthead from here up, so there is nothing to clear. */
  .hero-wrap, .rail-head { max-width: 1400px; margin: 0 auto; }
  .hero-wrap { padding-top: 0; }
  /* The rail carries the logo here, so the first heading needs no
     clearance — it takes the same padding as every other heading. */
  .collections-scroll > .rail-head:first-child { padding-top: 30px; }
  .rail-head { padding: 30px 4px 12px; }
  .rail-head h2 { font-size: 14px; }
  .rail-title { flex-direction: row; align-items: baseline; flex-wrap: wrap; gap: 0; }
  .rail-note { font-size: 14px; }
  .rail-note::before {
    content: "\2014";
    margin: 0 12px;
    font-style: normal;
    color: var(--dim);
  }
  .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; }
  /* The chips are always open here, so the highlighted chip IS the
     value — repeating it on the right only crowded the longer headings
     ("Reviews / Discovery Picks") onto three lines (5.14.1). */
  .search-top .filter-row .filter-val { display: none; }
  /* "REVIEWS / DISCOVERY PICKS" is 221px at the phone's spacing; this
     column leaves 205-229px. A touch tighter keeps it on one line. */
  .search-top .filter-row { padding-left: 12px; padding-right: 12px; }
  .search-top .filter-name { letter-spacing: 1.2px; }
  .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. */
  /* 26px, not the phone's --topbar-h sum. On a phone every page starts
     below the floating reel; from 900 up the reel is the rail's masthead
     and there is nothing overhead to clear, so a page that still reserved
     86px was reserving it for nothing — Bart: "on desktop and laptop that
     top padding is too large on Collections, Watchlist, and About".
     26 is not a new number: it is what .search-list and .search-top have
     used here all along, so these four now start on one line. */
  .wl-columns {
    max-width: 1240px;
    margin: 0 auto;
    padding: 26px 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,
  /* The reviewer's list gets the poster Search gets. It was on the small
     50x74 everywhere, which reads as a footnote next to their name. */
  .pv-author .pv-films .row-thumb,
  .search-list .row-thumb { width: 120px; height: 176px; border-radius: 10px; }
  /* A 120px poster takes the marker at its full size. */
  .wl-list .row-mk .mk-strip,
  .pv-author .pv-films .row-mk .mk-strip,
  .search-list .row-mk .mk-strip {
    height: 20px;
    border-radius: 0 0 10px 10px;
    font-size: 12px;
    letter-spacing: 1.4px;
  }

  /* TWO COLUMNS, FILLED ACROSS. Grid places in row order, so the newest
     two sit on the first row, the next two beneath — reading order, not
     two independent columns you have to scan down and then back up. */
  .pv-author .pv-films {
    display: grid;
    /* minmax(0, 1fr), NOT 1fr. A bare 1fr is minmax(AUTO, 1fr), and that
       auto floor is the row's max-content — poster + an unwrapped title +
       the bookmark. "Ford v Ferrari – Le Mans '66" is wider than half the
       column, so the track refused to go below its own content and the
       right column walked off the screen as the window narrowed. A zero
       floor lets the track shrink and hands the overflow to the title,
       which already ellipsises. */
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    gap: 4px 22px;
    align-items: start;
  }
  /* The empty state is a sentence, not a cell. */
  .pv-author .pv-films .list-empty { grid-column: 1 / -1; }
  .pv-author .pv-search { max-width: 520px; }

  /* TOP OF THE POSTER, not the middle of it. The row centres its parts
     vertically, which is right for a 74px thumbnail and wrong for a
     176px one: the title sat halfway down a tall poster with a hand's
     width of nothing above it. */
  /* Search and a reviewer's page are ONE list at two addresses — same
     poster, same type sizes, same wrapping. They had drifted into the
     big poster on both and the phone's 15px caption beside it. */
  /* 18px, not 14: the strip under each poster made the rows read as
     touching. */
  .pv-author .pv-films .row,
  .search-list .row { align-items: flex-start; padding: 18px 0; }
  .pv-author .pv-films { column-gap: clamp(28px, 3.5vw, 64px); }
  .pv-author .pv-films .row-main,
  .search-list .row-main { padding-top: 2px; }
  /* Sized for the poster beside it. 15px was the phone list's size and
     read as a caption next to a 120px poster. */
  .pv-author .pv-films .row-title,
  .search-list .row-title { font-size: 18px; }
  .pv-author .pv-films .row-meta,
  .search-list .row-meta { font-size: 13px; margin-top: 5px; }
  .pv-author .pv-films .row .stars,
  .search-list .row .stars { font-size: 13px; margin-top: 6px; }

  /* WRAP, DON'T TRUNCATE. The single-line ellipsis is a phone rule: in a
     60px-thumbnail row there is one line's worth of room and cutting is
     the only option. Here the row is 176px tall and mostly empty, so
     "Three Billboards Outsi…" was throwing away a title into space that
     was already paid for. The clipping lives in three places — the
     flex .row-title, the .t inside it, and the tagline — and all three
     have to be undone or the innermost one still cuts.
     align-items: flex-start so a type badge stays on the first line
     rather than floating beside a two-line title. */
  .pv-author .pv-films .row-title,
  .search-list .row-title { align-items: flex-start; overflow: visible; }
  .pv-author .pv-films .row-title .t,
  .search-list .row-title .t,
  .pv-author .pv-films .row-tagline,
  .search-list .row-tagline {
    white-space: normal;
    overflow: visible;
    text-overflow: clip;
  }
  .search-list .row-tagline { font-size: 14px; }
  /* THE BOOKMARK PULLED IN OFF THE COLUMN EDGE. It sat hard against the
     right of its cell, which put it a few pixels from the NEXT column's
     poster — close enough to read as that film's bookmark. Kept aligned
     (a ragged column of bookmarks chasing each title's width is worse),
     but moved inboard and given a wider gutter to sit in, so which film
     it belongs to is never a question. */
  .pv-author .pv-films .row { padding-right: 44px; }
  .pv-author .pv-films .row-side { align-self: flex-start; padding-top: 2px; }
}

/* ============================================================
   SURPRISE WAITS FOR 1200 — everything from here to the end of this
   block is the Surprise page's wide layout, and it opens 300px later
   than the rest of the wide layout does.

   The settings column is 300px on top of the 220px rail, and Surprise
   also wants a poster column and a text column beside them. Four
   columns fits at 1200 and does not fit below it. Bart: "at 900 add the
   left hand menu column, but not the right hand settings column - leave
   the Settings, Deal me three, and Surprise me again buttons centered
   along the bottom (same size as on mobile) and leave poster and text
   as they are; let these size to a maximum width of 1200, once over
   that put the column back on the right."

   So between 900 and 1199 Surprise keeps its PHONE presentation —
   poster full-bleed, text over it, the three buttons under the text —
   simply capped and centred (see the max-width rule further down).
   Nothing here is undone for that band; it just never applies.

   THE MATCHING HALF IS IN app.js: surSideColumn() builds the rail and
   moves the buttons into it, and it must ask the same question. It uses
   surWideMQ, which is this same 1200. Move one and you must move both.
   ============================================================ */
/* THE SETTINGS COLUMN: 900 AND UP, WHATEVER THE SHAPE.
   It was `1200 in landscape OR 900 in portrait`, which sounds reasonable
   and left a hole you could walk through. Bart did: hold the height,
   widen from narrow — mobile, then the column arrives at 900 (portrait),
   then at the width where the window turns landscape the condition needs
   1200 instead and the column VANISHES, then it comes back at 1200. A
   control that appears, disappears and reappears as one drag widens a
   window is worse than one that never appears.

   So the column is a plain width question — is there room for 300px
   beside the page — and 900 is where the answer becomes yes. Only the
   POSTER's arrangement turns on shape, which is the thing shape
   actually decides:

     portrait, any width    the phone's page: poster full width at the
                            top, text underneath.
     landscape under 1200   the same, but with the poster held inside
                            the panel so a short window cannot cut it off.
     landscape from 1200    poster and text side by side, the pair
                            centred in the panel.

   surWideMQ in app.js asks exactly this question and must stay
   identical — it is what BUILDS the rail, by moving the buttons and the
   settings sheet into it. */
@media (min-width: 900px) {
  /* ---------------- 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's half of these four rules lives in its own 900 block
     just after this one: they were written as one shared rule, and this
     block now opens at 1200, which would have left Search's chips dead
     under the cursor between 900 and 1199. Same declarations, two
     homes — keep them in step. */
  .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. */
  .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. */
  .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. */
  .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. */
  /* the WRAPPER is what gives up the 300px, so both faces match it */
  #sur-flip { right: 300px; }
  .sur-deal { right: 300px; }

  .sur-panel .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).

     A SPACER, not padding. This was padding-bottom on .feed-extra-inner,
     and padding is exactly the wrong tool: overflow:hidden clips to the
     PADDING box, not the content box, so with the excerpt shut the
     collapsed row still resolved to 61px and half the "Read review"
     button showed below the fold. A flex item's height lives inside the
     content box, so it is clipped away with everything else. */
  .sur-panel .feed-extra-inner::after { content: ""; flex: 0 0 61px; }
  .sur-panel .feed-title { font-size: clamp(38px, 3.6vw, 54px); }
  .sur-actions { right: clamp(24px, 3vw, 48px); }
}

/* ---- Surprise between 900 and 1199: the phone page, capped ----
   No settings column here (see the gate above), so the poster and the
   text keep the shape they have on a phone. Left to itself that shape
   would stretch across an 1100px window, which a full-bleed poster with
   the text laid over it does not survive. Bart: "let these size to a
   maximum width of 1200".

   HOW THE CENTRING WORKS, since it is not obvious. The view is
   absolutely positioned with `left: var(--rail); right: 0` and an auto
   width, so it fills the space beside the rail. Applying a max-width
   makes it over-constrained — left, width and right can no longer all
   hold — and CSS resolves that by splitting the leftover between the
   two margins when both are auto. So max-width plus `margin: auto` is
   the whole mechanism; don't "help" it with width:100% or right:auto,
   either of which takes the centring back out. Everything inside is
   positioned against .sur-panel, so capping the view carries the whole
   page with it. */
/* LANDSCAPE UNDER 1200: the phone's page, with the poster held inside
   the panel.
   THE 1200px CAP THAT USED TO BE HERE IS GONE. It existed because there
   was no settings column at these widths and a full-bleed poster with
   text over it does not survive being stretched across 1100px. There is
   a settings column now — it starts at 900 — so the panel is already
   the window less 220 and less 300, which is the narrowing the cap was
   for. Capping as well would centre the page inside its own grid cell,
   away from the column it belongs beside. */
@media (min-width: 900px) and (max-width: 1199px) and (min-aspect-ratio: 5/4) {

  /* THE WHOLE POSTER, INSIDE THE WINDOW, IN ITS OWN RATIO.
     The phone rule is `width: 100%; height: auto`, which bounds ONE side
     and lets the other go where it likes. That is right on a phone,
     where the panel is portrait and taller than any poster needs, and
     wrong the moment the panel is wider than it is tall: at 1100x820
     the poster came out 880 wide and therefore 1320 tall in an 820px
     window, and ran off the bottom. Bart: "size the height of the
     poster to be max the height of the window; and the same for the
     width; but the ratio of the poster must be respected - ie the full
     poster should always be in the window."

     THE FRAME IS THE PICTURE'S BOX, NOT THE PANEL'S — and that is the
     whole point of it. The first attempt filled the panel and let
     object-fit: contain letterbox the picture inside; the picture was
     then whole, but overflow:hidden was clipping at the PANEL's edge,
     far outside it, so bb-poster-zoom simply grew the poster into its
     own letterbox and its edges moved. Bart: "the zoom then moves the
     edges of the poster ... I want the poster window's edges to remain
     fixed while the poster breathes (like it does in laptop mode)."
     Laptop mode gets that for free because its frame hugs the poster.

     Bounding BOTH sides while keeping the ratio needs the ratio, and
     only the file knows it — so it arrives from app.js as --bb-art-ar,
     exactly as .feed-art's does. This is a RATIO, not a position: it is
     a fact about the file, it is the same on every render, and nothing
     but the poster's own box depends on it. (The measured values that
     caused so much trouble in 4.88.x were POSITIONS other elements were
     hung off. The text and the buttons here are positioned against the
     panel and do not know the poster exists.) The 2/3 fallback is what
     almost every poster is, so a ratio that never arrives is a slightly
     wrong crop rather than a collapsed box.

     inset:0 + margin:auto + auto width/height + aspect-ratio + both
     maxes = the largest box of the file's ratio that fits the panel,
     centred. Same construction as .feed-art, which is where it was
     proved. */
  /* The whole block is landscape-only now (see its media query), which
     is what this rule was always FOR: it exists because "the poster came
     out 880 wide and therefore 1320 tall in an 820px window", and a
     window is only shorter than a 2:3 poster is tall when it is wider
     than it is high. */
  .sur-panel .sur-art {
    display: block;
    position: absolute;
    inset: 0;
    margin: auto;
    aspect-ratio: var(--bb-art-ar, 2 / 3);
    width: auto;
    height: auto;
    max-width: 100%;
    max-height: 100%;
    overflow: hidden;
  }
  .sur-panel .sur-art .sur-bg {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: contain;
  }
}

@media (min-width: 900px) {
  /* SEARCH'S HALF of the chip-hover rules above. They were one shared
     rule with .sur-rail until the Surprise block moved to 1200; Search's
     wide layout still starts at 900, so its chips need these here. */
  .search-top .chip:hover {
    border-color: var(--accent);
    background: rgba(201, 132, 58, 0.09);
    color: var(--fg);
  }
  .search-top .chip.active:hover { color: var(--accent); }
  .search-top .chip.rating-off:hover { opacity: 0.8; }
  .search-top .chip {
    transition: border-color 0.16s ease, background-color 0.16s ease, color 0.16s ease, opacity 0.16s ease;
  }

  /* ---------------- About ---------------- */
  .about-scroll { max-width: 860px; margin: 0 auto; padding-top: 26px; }
}


/* 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: 900px) {
  #strip-head { display: none; }
}

.home-extra-rails { display: none; }
/* 1100, NOT the 900 the rest of the wide layout starts at — this is the
   one piece of it that waits.

   At 900 the page is four columns: rail, poster, review text, explore.
   The hero row is then about 450px wide and the poster came out tiny —
   Bart: "the 4 columns is not right at 900, the 'hero' poster becomes
   tiny ... between 1100 and 900 we should drop the entire right hand
   column, it just claims too much space on a narrower desktop
   viewport". So between 900 and 1099 the page is rail + hero, and the
   explore column appears only once there is width to spare for it.

   Hiding it is not enough on its own: the grid keeps a track for a
   display:none item, so see the --bb-panel override after the wide
   block, which collapses that track (and with it the next-slide
   arrow's offset, which is measured from the same variable).

   AND THE WINDOW HAS TO BE CLEARLY LANDSCAPE. Bart, 21 Sep 2026: "hide
   the right hand column in desktop mode on the homepage so the main hero
   gets all the top space; only add the right hand column when the window
   is wider than it is tall". Width alone was never the real question — a
   1900px window 1790px tall is 1100 twice over and still has no room to
   spare sideways, because the hero card is as wide as the row lets it be
   and every pixel the column takes comes off the still.

   ═══ 5/4, NOT 1/1, AND THE REASON IS THE BROWSER'S OWN CHROME ═══
   A media query measures the VIEWPORT; a person measures the WINDOW. On
   the window Bart was describing those are two different shapes and fall
   on opposite sides of square: the window is 2522x2616 (0.96, taller
   than wide, which is what he saw), and with Safari's toolbar and
   bookmarks bar off the top the page area is about 2522x2377 — 1.06,
   fractionally WIDER than tall. A literal 1/1 would have kept the column
   on the very window the request was about.
   5/4 is that instruction as it behaves once the chrome is accounted
   for, and it leaves enough margin that the rule does not flip when the
   window is dragged a few pixels. For reference: 1440x900 is 1.60 and
   1920x1080 is 1.78, so a laptop keeps its column.
   THE TWO QUERIES MUST AGREE — see the --bb-panel override further down,
   which collapses the grid track. Change the ratio in both or in
   neither. */
@media (min-width: 1100px) and (min-aspect-ratio: 5/4) {
  .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; }
/* Pinned, so the whole bottom band is a KNOWN height at every window
   size — the same contract the poster strip above it already keeps.
   Without this the row still breathed: align-items:stretch matches both
   cards to the taller one, and the surprise card grows when its text
   wraps. Fixing the spotlight alone was not enough.
   180px is measured, not chosen: the deal-fan inside the surprise card is
   130 and its padding 22 top and bottom, so 174 is the content and 180 is
   the smallest round number that never clips it. The card carries
   overflow:hidden already (.home-cta-card), so an overrun would crop
   rather than spill. */
.home-cta-row { display: flex; gap: 16px; align-items: stretch; height: 180px; }
.home-cta-card {
  flex: 1 1 0;
  min-width: 0;
  border-radius: 10px;
  border: 1px solid var(--line);
  overflow: hidden;
}
/* THE DEAL CARD AND THE WORLD EXPLORER CARD ARE ONE SHAPE.
   Bart: "make all 3 cards the same size, same font, same alignment".
   The two button cards share every selector below rather than having
   the values copied into a second block, so there is no way for one to
   be adjusted and the other left behind. (The catalogue card is a
   <div>, not a <button>, and can only share the heading — see the
   note on .home-spotlight-label.) */
.home-cta-surprise,
.home-cta-world {
  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,
.home-cta-world: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%);
  /* FIXED height, not an aspect ratio.
     16/9 tied this card's HEIGHT to the window's WIDTH: widening the
     browser widened the card, which made it taller, which took the
     difference straight off the hero — the one band that should absorb a
     change in size. Measured: 238px tall at a 1055px viewport, 315 at a
     wide one, and the whole bottom panel with it.
     The stills inside are absolutely positioned and object-fit:cover, so
     a fixed box crops them rather than squashing them, and the strip of
     posters above already works exactly this way — a fixed height that
     does not care how wide the window is. */
  height: 180px;
  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;
  /* .still-card carries aspect-ratio: 16/9 for its normal use in a rail.
     Here it is absolutely positioned with inset:0, and an over-constrained
     absolute box with height:auto resolves its height from the RATIO and
     then ignores bottom:0 — so on a 560px-wide card it became 315px tall
     inside a 180px window, and .still-body, which is pinned to the bottom
     of the CARD, sat 135px below anything visible. The wider the window,
     the further the title and rating fell out of sight.
     Releasing the ratio lets the height come from the insets, so the card
     is exactly the height of the box it sits in and its caption is pinned
     to the bottom of THAT. The image is object-fit:cover, so it crops to
     the new shape instead of squashing. */
  aspect-ratio: auto;
  height: auto;
}
.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; }

/* THE WORLD EXPLORER CARD'S GLOBE.
   Sits in the same slot as the deal fan, and is deliberately the same
   height as it (130px) so the two cards' artwork lines up and the row
   keeps the height it already had. Square, because a globe is.

   Turning, not spinning: 24 seconds for a meridian to cross the face.
   Slow enough to read as movement rather than motion, and cheap —
   four transforms on a compositor-friendly property, nothing measured,
   nothing scripted. */
.home-globe {
  position: relative;
  flex: 0 0 auto;
  width: 130px;
  height: 130px;
}
.home-globe svg { display: block; width: 100%; height: 100%; overflow: visible; }
.hg-sphere { fill: rgba(232, 192, 101, 0.05); }
/* THE LAND, sliding one full copy's width left over 15 seconds and
   snapping back to a seam that is identical — so it never appears to.
   150 is the width of one copy in the SVG's own units (see
   BB_GLOBE_LAND in app.js); the two must move together.
   Filled rather than outlined: at this size an outline is a smudge,
   while a flat shape at low opacity reads as a continent. */
.home-globe .hg-land {
  fill: rgba(232, 192, 101, 0.42);
  stroke: none;
  animation: bb-globe-spin 15s linear infinite;
}
@keyframes bb-globe-spin {
  from { transform: translateX(0); }
  to   { transform: translateX(-150px); }
}
/* The gradient itself is an SVG <radialGradient> in the mark-up — `fill`
   does not take a CSS gradient — so there is nothing to set here but the
   fact that it is decoration. */
.hg-shade { pointer-events: none; }
/* The rim is drawn LAST, over the meridians, so their ends meet a clean
   edge instead of stopping a hair short of one. */
.hg-rim { fill: none; stroke: rgba(232, 192, 101, 0.55); stroke-width: 1.2; }
.home-globe .hg-par line {
  stroke: rgba(232, 192, 101, 0.22);
  stroke-width: 1;
}
/* A STATIC GRATICULE now, not sweeping meridians. When the globe was a
   wireframe the ellipses turning WERE the rotation; with land on it,
   two different motions at two different speeds fought each other and
   the thing stopped reading as one solid object. So the graticule
   stands still, like the lines printed on a real globe, and the land
   does the turning. Four ellipses at fixed widths are four meridians
   seen at four angles. */
.home-globe .hg-mer ellipse {
  fill: none;
  stroke: rgba(232, 192, 101, 0.22);
  stroke-width: 1;
  transform-box: fill-box;
  transform-origin: 50% 50%;
}
.home-globe .hg-mer ellipse:nth-child(2) { transform: scaleX(0.72); }
.home-globe .hg-mer ellipse:nth-child(3) { transform: scaleX(0.44); }
.home-globe .hg-mer ellipse:nth-child(4) { transform: scaleX(0.16); }
@media (prefers-reduced-motion: reduce) {
  .home-globe .hg-land { animation: none; }
}

/* BB-HOME-EXTRAS:END */


/* ---------------- "this is not the live site" ----------------
   Set by app.js when the app is served from anywhere other than the
   domain root — see IS_SANDBOX. Deliberately a colour that appears
   NOWHERE else in the palette, so it can never be mistaken for part of
   the design, and pointer-events:none so it cannot get in the way of
   anything. Nothing here needs configuring per environment: the same
   plugin zip goes to both sites and the app works out which it is. */
/* Inside a workspace preview the sandbox frame and label are furniture a
   reader will never see — and they would be the one thing in "exactly
   what readers will see" that readers never get. */
html.bb-previewing::before,
html.bb-previewing::after { display: none !important; }

html.bb-sandbox::before {
  content: "";
  position: fixed;
  inset: 0;
  border: 3px solid #c0392b;
  z-index: 9998;
  pointer-events: none;
}
html.bb-sandbox::after {
  content: "SANDBOX";
  position: fixed;
  top: 0;
  left: 50%;
  transform: translateX(-50%);
  z-index: 9999;
  background: #c0392b;
  color: #fff;
  font-family: var(--font-display), "Arial Narrow", sans-serif;
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 2.5px;
  line-height: 1;
  padding: 5px 12px 6px;
  border-radius: 0 0 7px 7px;
  pointer-events: none;
}


/* ---------------- component mount ----------------
   The host gives a component an empty, scrollable box the size of a view
   and nothing else: no padding, no typography of its own, no assumptions
   about what goes in it. Everything else it wants, it brings — or asks
   for through the API, which is the point. */
#view-component { overflow: auto; }
.bb-comp-loading {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
  color: var(--dim);
  font-size: 14px;
}

/* ============================================================
   BB-LAPTOP:START — the SHORT wide window
   ============================================================
   Additive and reversible: delete from START to END and the layout is
   exactly what it was. Matching mark-up change is the <br class="bb-wide-br">
   in index.html.

   Readers on a laptop in full screen were getting a different site from
   the one this layout was judged on. Measured at 1440x790, which is a
   MacBook full screen:

     Latest       hero 199px — 25% of the screen. The strip (294) and the
                  discover panel (297) are FIXED heights and the hero got
                  the remainder. The poster inside it came to 131px, which
                  is smaller than the thumbnails in the rail beneath it,
                  and the "Read review" button was pushed out of the slide
                  entirely.
     Surprise     the poster 1363px tall in a 790px panel, sitting at
                  top:-573 — two thirds of it above the top edge.
     Collections  the hero 664px, 84% of the screen, so the first rail
                  began at 815px and was never seen.

   One cause wearing three hats: heights that answer to WIDTH, or to
   nothing, and never to height. The fixes below tie them to the viewport
   with the old values as their ceiling, so a tall window is untouched.

   WHY 1280 AND NOT 1024 (fixed 2 Sep 2026).
   The first cut of this block opened at 1024px — the same breakpoint
   desktop mode itself uses — so it did not ADD a mode, it REPLACED
   desktop. Bart: "now desktop mode and laptop mode are the same... that
   is not what I want." Desktop is the 1024-1279 band and must stay
   byte-for-byte what it was; this block is the wide band on top of it.
   1280 is the floor because the windows that broke are full-screen
   laptops - 1440x790 on a 13" Air, 1512x982 on a 14" Pro - while a
   desktop browser window that someone has NOT maximised lands under it.
   Moving the mode is this one number plus the .bb-wide-br query at the
   end of the block; nothing else in here reads a width.
   ============================================================ */

/* ---- Shared with desktop: the catalogue card ----
   MUST STAY ABOVE THE 1280 BLOCK. The wide band overrides two of these
   declarations at the same specificity, so cascade order is what decides
   the winner; move this below and the 16:9 override silently stops
   working.

   THE CATALOGUE CARD IS A MOUNT, NOT AN OVERLAY. The label used to lie on
   top of the picture, covering the part people came to look at, and the
   card WAS the picture. Now the card holds the picture: a heading band
   across the top on the card's own ground, and the image inset by 10px on
   the other three sides so it is visibly framed. Same warm ground as "Let
   us deal you three" beside it — these two are the first of a family
   (World Explorer, timeline, whatever follows) and they should already
   look like one. Bart asked for this in desktop mode too, so it opens at
   1024, not at the block's own 1280. */
@media (min-width: 900px) {
  .home-cta-spotlight {
    display: flex;
    flex-direction: column;
    gap: 0;
    aspect-ratio: auto;
    height: auto;
    padding: 0 10px 10px;
    background: linear-gradient(135deg, rgba(201, 132, 58, 0.16), #1c1913);
  }
  .home-cta-spotlight .home-spotlight-label {
    /* Last in the mark-up, so a column puts it under the picture; order
       lifts it without touching the HTML. */
    order: -1;
    position: static;
    background: none;
    border: 0;
    border-radius: 0;
    padding: 10px 11px 9px;
    /* THE SAME HEADING AS THE CARD NEXT DOOR. These four values are
       .home-cta-kicker's, copied deliberately rather than shared: the
       kicker is inside a <button> and this is a plain <span>, so there
       is no selector that reaches both without dragging the button's
       layout along. If the kicker changes, change these with it. */
    font-size: 12px;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--accent);
  }
  /* 16:9 IN BOTH MODES, AND A ROW BUILT FOR THREE.
     The picture is a backdrop and should be shown as one, so the ratio
     wins and the ROW gives way — height goes to auto and the card takes
     the height its picture asks for.

     But at HALF the row's width a 16:9 picture is tall, and the band
     went from 180px to 280 — Bart: "far too large again... bring it
     back to its previous size, and yes that means 'from the catalogue'
     will end up narrower. Don't make 'deal you three' too large just to
     fill the space."

     So the cards are sized as THIRDS and there are only two of them.
     The empty third is deliberate: "plan for 3 cards there, leave the
     third card hidden for now, we'll fill it later" — World Explorer,
     a timeline, whatever comes. Nothing to un-hide when it arrives:
     append a .home-cta-card and it lands in the slot already reserved
     for it. At 1150px that makes each card 300 wide, its picture 280x157,
     and the band ~197 — near the 180 it was, instead of 280. */
  /* THE BAND IS AS SHORT AS ITS CONTENT. Bart: "bottom rail is too high
     ... reduce padding to make it consistent with the rail above it".
     The rail above is .feed-strip, whose vertical padding is
     clamp(7px,1.1vh,12px) / clamp(8px,1.3vh,14px) — the same numbers
     here instead of a flat 28/36, so the two bands read as a pair. The
     height this gives back needs no redistributing: the grid's rows are
     `minmax(0,1fr) auto`, so every pixel row 2 stops using goes to the
     hero on its own. */
  .home-extra-rails {
    padding: clamp(7px, 1.1vh, 12px) clamp(24px, 3vw, 56px) clamp(8px, 1.3vh, 14px);
  }
  .home-discover-head { margin-bottom: 10px; }
  .home-cta-row {
    height: auto;
    align-items: stretch;
    justify-content: flex-start;
  }
  .home-cta-card { flex: 0 0 calc((100% - 32px) / 3); }
  /* THE TWO CARDS ARE BUILT THE SAME WAY. Side by side in a third of the
     row, the deal card kept trying to be a text block with a picture
     beside it while the catalogue card was a heading with a picture
     under it — which is why no amount of adjusting the gap made the pair
     look related. So this one is stacked too: heading, then its line,
     then the artwork.

     The kicker's four type values and its padding are lifted verbatim
     from .home-spotlight-label, and the card's padding from
     .home-cta-spotlight, so the two headings land on the same line and
     read as the same thing — Bart: "make the heading 'not sure what to
     watch' the exact same font, size and colour as 'From the Catalogue'
     next to it with exact same vertical placement". If one changes,
     change both; there is no selector that reaches a <span> in a
     <button> and a <span> in a <div> without dragging their layout
     along. */
  /* A grid, not a column: the heading spans the top so it can line up
     with FROM THE CATALOGUE, and under it the title and the cards sit
     side by side — Bart: "'let us deal you three' on the left, the three
     cards on the right". A flex column could do the first of those or
     the second, not both. */
  .home-cta-surprise,
  .home-cta-world {
    min-height: 0;
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    grid-template-rows: auto minmax(0, 1fr);
    grid-template-areas:
      "kicker kicker"
      "title  fan";
    align-items: center;
    gap: 0;
    padding: 0 10px 10px;
  }
  /* Dissolved, so the kicker and the title become items of the card's
     own grid and can be placed into separate areas. */
  .home-cta-surprise .home-cta-body,
  .home-cta-world .home-cta-body { display: contents; }
  .home-cta-surprise .home-cta-kicker,
  .home-cta-world .home-cta-kicker { grid-area: kicker; }
  .home-cta-surprise .home-cta-title,
  .home-cta-world .home-cta-title { grid-area: title; }
  .home-cta-surprise .home-deal-fan,
  .home-cta-world .home-globe { grid-area: fan; }
  .home-cta-surprise .home-cta-kicker,
  .home-cta-world .home-cta-kicker {
    padding: 10px 11px 9px;
    font-size: 12px;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--accent);
  }
  .home-cta-surprise .home-cta-title,
  .home-cta-world .home-cta-title {
    padding: 0 11px;
    font-size: 17px;
    line-height: 1.2;
  }
  /* Bigger than it was, and in its own grid cell on the right of the
     title. transform-origin on the right edge so growing it does not
     push it into the title. */
  .home-deal-fan,
  .home-globe {
    flex: 0 0 auto;
    align-self: center;
    justify-self: end;
    transform: scale(0.78);
    transform-origin: 100% 50%;
  }
  /* The globe is drawn as SVG, so it is resized rather than scaled — a
     transform would soften its hairlines for no reason. Smaller than the
     fan because the card it sits in is shorter now. */
  .home-globe { width: 88px; height: 88px; transform: none; }
  /* THE WORLD CARD HAS NO KICKER, so the grid's first row ("kicker")
     was empty and 0px tall — the title and the globe started at the very
     top of the card with the deal card's 0 top padding, and the globe sat
     1px under the border with 11px beneath it (measured at 1351px). One
     row, equal padding top and bottom, and a globe 10px smaller so the
     padded card is no taller than before and the row keeps its height.
     Bart, 16 Sep: "more padding at the top so both the text on the left
     and the globe are vertically centered in the box". */
  .home-cta-world {
    grid-template-rows: minmax(0, 1fr);
    grid-template-areas: "title fan";
    padding: 10px;
  }
  .home-cta-world .home-globe { width: 78px; height: 78px; }
  /* THE CAPTION IS UNDER THE PICTURE, NOT ON IT (5.16.4).
     Title and rating used to be pinned across the bottom of the still,
     over the part of the frame someone is looking at, because that is
     what .still-body does in a rail where the card IS the picture. In
     the right-hand column there is vertical room going spare, so they
     come down onto the card's own ground — Bart: "you can move the
     title and rating below the image rather than on top of it".

     A GRID WITH ONE CELL, not two absolute layers. The two crossfading
     stills have to occupy the same place AND give the box its height,
     which absolute positioning cannot do (nothing is in flow, so the
     container collapses — that is why this was a fixed 16/9 window with
     the cards inset into it). Both cards are placed in cell 1/1
     instead: they overlap exactly as before, they still cross-fade on
     opacity, and the box is as tall as they are, caption included. */
  .home-cta-spotlight .home-spotlight-imgs {
    position: relative;
    inset: auto;
    width: 100%;
    aspect-ratio: auto;
    display: grid;
    flex: 0 0 auto;
    border-radius: 0;
    overflow: visible;
  }
  /* A golden hairline so the picture reads as a mounted print rather
     than a hole in the card — Bart: "needs to pop more, give it a white
     or golden hairline or something".
     ON THE PICTURE ITSELF now that the picture is a real in-flow element
     with the caption below it. It used to be a ::after across the whole
     container, because the container WAS the picture; drawn there today
     it would edge the caption too. An outline pulled inside by its own
     width behaves like an inset border on a replaced element — a border
     would be added outside the image's box and nudge everything below it
     by 2px. */
  .home-cta-spotlight .home-spotlight-imgs::after { content: none; }
  /* THE CARDS STAY STACKED — see the grid note above. They cross-fade
     over each other, which needs them in the same place; the single grid
     cell is what keeps them there now that they are in flow. Each card
     is itself a column: picture, then caption. */
  .home-cta-spotlight .home-spotlight-imgs .still-card {
    position: static;
    grid-area: 1 / 1;
    display: flex;
    flex-direction: column;
    width: auto;
    height: auto;
    background: none;
    overflow: visible;
  }
  /* The scrim exists to keep a caption legible over artwork. There is no
     caption over the artwork here any more, so it would only be dimming
     the bottom of the picture for nothing. */
  .home-cta-spotlight .home-spotlight-imgs .still-card::after { content: none; }
  /* The "Review" / "Discovery Pick" pill and the Series badge are
     overlays for a rail card whose only surface is the picture. Here the
     card has its own ground and the picture is small, so they sat on top
     of the one thing the card is for — Bart: "remove the pill 'review'
     from the image as well". */
  .home-cta-spotlight .home-spotlight-imgs .mk-pill,
  .home-cta-spotlight .home-spotlight-imgs .type-badge { display: none; }
  .home-cta-spotlight .home-spotlight-imgs img {
    position: static;
    z-index: auto;
    width: 100%;
    height: auto;
    aspect-ratio: 16 / 9;
    object-fit: cover;
    border-radius: 7px;
    /* THE SAME HAIRLINE THE PLAYING CARDS WEAR. Lifted from
       .home-deal-mini's border so the two cards' artwork is edged
       identically — Bart: "make the hairline the same as the hairline
       around the playing cards next to it". Change them together. */
    outline: 1px solid rgba(232, 192, 101, 0.35);
    outline-offset: -1px;
  }
  /* Down onto the card's own ground: no scrim under it, no shadow on it,
     and the picture's own 7px radius is what the eye lines the text up
     against, so the text starts flush with the picture's left edge. */
  .home-cta-spotlight .home-spotlight-imgs .still-body {
    position: static;
    padding: 8px 0 0;
    gap: 4px;
  }
  .home-cta-spotlight .home-spotlight-imgs .still-title {
    font-size: 15px;
    text-shadow: none;
  }
}

@media (min-width: 900px) {

  /* ---- Latest: the panel moves BESIDE the hero ----
     It was a full-width band under the strip, costing 300px of height the
     hero needed, while the space to the right of the hero text sat empty.
     Same three blocks, rearranged: hero and panel share the top row, the
     strip spans the bottom. */
  #view-home {
    /* Named, because the next-slide arrow has to know how wide the panel
       is — see #feed-arrow-r below. One number, two users. */
    /* Floor down from 260 to 230: the panel is a right-hand column at
       every width now, and at 900px the hero needs what the panel does
       not. Bart: "in desktop mode, it should also be on the right as a
       column, but a narrower version (so smaller cards in it)". The cards
       are panel-width, so narrowing the panel IS the smaller cards. */
    --bb-panel: clamp(230px, 22vw, 360px);
    display: grid;
    grid-template-columns: minmax(0, 1fr) var(--bb-panel);
    grid-template-rows: minmax(0, 1fr) auto;
  }
  #view-home > #feed { grid-column: 1; grid-row: 1; min-height: 0; }
  #view-home > .home-extra-rails {
    grid-column: 2;
    grid-row: 1;
    border-top: 0;
    border-left: 1px solid var(--line);
    padding: clamp(16px, 2.6vh, 28px) clamp(16px, 1.4vw, 22px);
    overflow: hidden;
  }
  /* THE RAIL RUNS FULL WIDTH, THE PANEL STAYS IN ROW 1.
     Two ways to close the empty bottom-right corner: span the panel down
     or span the rail across. 4.88.7 tried the first — Bart: "I think it
     would look nicer for the bottom rail to claim full width than the
     right hand column to claim full height". So the rail spans both
     columns and the panel keeps row 1, which is where the earlier rule
     was always trying to put it.

     AND THE SELECTOR HAS TO BE .rail-wrap. `#view-home > .feed-strip`
     matched nothing: app.js wraps the strip in a .rail-wrap to hang the
     scroll arrows on, so the grid item is the WRAPPER and the strip is
     its child. That rule had been dead the whole time — the rail sat in
     column 1 by auto-placement, which LOOKED deliberate, which is why
     the corner it left behind read as a layout decision rather than as
     a selector that never matched. Both selectors are kept so this
     survives the wrapper being added or removed. */
  #view-home > .feed-strip,
  #view-home > .rail-wrap { grid-column: 1 / -1; grid-row: 2; }
  #view-home > .strip-head { grid-column: 1 / -1; }
  .feed-slide { padding-right: clamp(24px, 3vw, 56px); }
  /* THE ARROW LIVES IN THE HERO, NOT AT THE WINDOW EDGE. It is absolutely
     positioned against #view-home, so once the panel took the right-hand
     column a right offset of a few pixels put the arrow ON the panel,
     pointing at nothing it controls. Offset by the panel's own width.

     28, not 12: the hero is a card now, its line 14px in from the hero
     column's right edge, and at 12 the arrow hung two pixels over that
     line. Inside the card, clear of it. */
  #feed-arrow-r { right: calc(var(--bb-panel) + 40px); }

  /* ---- the strip gives height back ---- */
  .feed-strip { padding: clamp(7px, 1.1vh, 12px) clamp(24px, 3vw, 56px) clamp(8px, 1.3vh, 14px); }
  /* THE STRIP TAKES THE HEIGHT THE CARD GAVE BACK. The hero card hugs
     its contents now, so on a tall window it no longer fills the row —
     and the posters below it are the natural place for what is left.
     The cap goes 144 -> 220; the 11.5vh is unchanged, so a laptop, where
     that clamp was already resolving well under 144, sees no difference
     at all. Bart chose this when the card stopped stretching. */
  .feed-strip button { flex: 0 0 clamp(84px, 11.5vh, 220px); }
  .feed-strip img { width: 100%; height: auto; aspect-ratio: 2 / 3; }
  .feed-strip .poster-title { font-size: clamp(11px, 1.5vh, 13px); }

  /* ---- the two discover cards, stacked in a narrow column ---- */
  .home-discover-head { margin-bottom: clamp(6px, 1.4vh, 18px); }
  .home-cta-row { display: flex; flex-direction: column; height: auto; gap: 12px; }
  /* The >=1024 rule gives every card a flex-basis of one third — which is
     a WIDTH there and would be a HEIGHT here, the row having turned into
     a column. Back to content-sized. */
  .home-cta-card { flex: 0 0 auto; }
  .home-cta-surprise { min-height: clamp(88px, 13vh, 132px); }
  /* SHORTER THAN THE OTHER TWO, on purpose. It lost its kicker (see
     bbAddWorldCard) and its artwork is a disc rather than a fan of three
     cards, so it has less to hold and stretching it to match only added
     air — Bart: "make the card less high". In a column, cards of
     different heights are ordinary; it is the horizontal row that wants
     them equal, and there they still are. */
  .home-cta-world { min-height: 0; }

  /* The catalogue card is defined at >= 1024 (the block ABOVE this one),
     16:9 picture and all. Nothing to add here any more. */

  /* ---- A review's hero stops at the text column ----
     Full-bleed across both tracks it came out 1256x474 from a 1280x720
     backdrop — the picture was being painted at 1312 wide, past its own
     native size, which is why Bart called it "too large and not sharp".
     There is no sharper file to reach for; the fix is to stop asking for
     more pixels than exist. Held to column one it is 956x398 and the
     source is drawn at 95% of native, and the hero's edges now line up
     with the prose beneath it instead of overhanging it.

     Height stays a DEFINITE length rather than an aspect-ratio, for the
     reason the rule this overrides gives: an auto grid row cannot derive
     a height from aspect-ratio while it is being sized, and row 1
     collapsed to 0 when it tried. Same 2.4:1, measured off the column
     the hero actually occupies — the window less the tab rail less the
     300px sidebar. */
  /* 25% TALLER (5.20.29) — 2.4:1 became 1.92:1, and the floor and the
     cap went up by the same quarter (280 -> 350, 460 -> 575). Bart: "the
     dimensions on the desktop review page don't work so well - you'd
     have to force the focus/zoom higher which then does not work well on
     the other two places where the hero is used. Maybe we should make
     the hero image space on desktop review 25% taller so it fits
     better?" The same still is shown at about 16:9 on desktop Latest and
     at about 1.2-1.6:1 on a phone's review page; a 2.4:1 letterbox cut
     so much off the top and bottom that a framing chosen for those two
     cropped heads here. Measured at 1440x900 (the workspace preview):
     920x383 before, 920x479 now. Row 1 of the grid below must say the
     same — it does. */
  .bb-layout-aligned .ov-hero {
    grid-column: 1;
    height: clamp(350px, calc((100vw - var(--rail) - 300px) / 1.92), 575px);
  }
  /* ROW 1 IS THE HERO'S HEIGHT, EXACTLY — the same expression as the rule
     above; change one, change both. The side column below spans rows 1-2,
     and when it is taller than hero + review the grid shares the excess
     between BOTH auto rows, which opened a gap under the hero: measured on
     /movie/automata-510/ at 1351px, row 1 was 599px against a 346px hero,
     and the title started 253px below the picture. A definite first row
     leaves row 2 as the only track that can grow (row 1 → 346.25px, rail
     still below the column). NOT "auto 1fr": a flexible row is ignored
     when sizing a spanning item, so the column overflowed row 2 and the
     full-width rail started on top of it (884px against a column ending
     at 1360px). Short reviews and every suggestion hit this. */
  .bb-layout-aligned #review-overlay .ov-scroll {
    grid-template-rows: clamp(350px, calc((100vw - var(--rail) - 300px) / 1.92), 575px) auto;
  }
  /* ...WHICH LEAVES ROW 1, COLUMN 2 EMPTY, so the sidebar has to come up
     and fill it. It was pinned to row 2 back when the hero spanned both
     tracks and there was nothing beside it to sit next to; now there is,
     and leaving it down there put a black rectangle in the top-right —
     Bart: "the individual review page looks a bit funny with the black
     top right corner in laptop mode - move the right hand column to the
     top". Spanning two rows rather than sitting in row 1 alone, because
     the panel is far shorter than the review and align-self:start (from
     the rule this extends) keeps it wrapping its content either way.
     margin-top goes with it: 25px was the offset that dropped it clear
     of the hero's bottom edge, and there is no longer a hero above it. */
  .bb-layout-aligned .ov-body > .ov-side {
    grid-row: 1 / span 2;
    margin-top: 0;
  }

  /* ---- Collections: the hero is a landscape card like any other ----
     flex: 0 0 100% made it full-bleed and a different animal from the
     rails below. This is the exact basis .still-card uses, so it matches
     the landscape rails in width and, being 16:9, in height. */
  /* Collections hero: see the ">= 1024" rule after this block. It is
     deliberately NOT here — Bart asked for that one "also in desktop and
     phone mode", so it is the one fix that keeps the old breakpoint. */

  /* ---- Surprise: TWO COLUMNS, text left, poster right ----
     A 2:3 poster in a panel wider than it is tall loses ~40% of its
     height to a cover crop, and no offset keeps both the face and the
     title — nudging it only swaps one casualty for another. So the
     poster stops pretending to be a background: it gets its own column
     at the right, whole and contained, and the copy gets the left.

     WHY THE !important AND WHY OUR OWN KEYFRAMES.
     app.js writes `top`, `height`, `transform` and `--kb-max` straight
     onto these <img>s to frame the face inside a full-width backdrop.
     Those numbers are computed against a panel-wide image and are wrong
     for a 36% column — measured, the framing translate pushed the
     poster ~180px past the panel's right edge. Inline styles beat a
     stylesheet, but a CSS ANIMATION beats an inline style, so animating
     transform does two jobs at once: it neutralises the stale framing
     translate and it is the breathing. Don't "simplify" this to
     transform: none — that lands a poster that sits dead still. */
}

/* ============================================================
   SURPRISE WAITS FOR 1200 — everything from here to the end of this
   block is the Surprise page's wide layout, and it opens 300px later
   than the rest of the wide layout does.

   The settings column is 300px on top of the 220px rail, and Surprise
   also wants a poster column and a text column beside them. Four
   columns fits at 1200 and does not fit below it. Bart: "at 900 add the
   left hand menu column, but not the right hand settings column - leave
   the Settings, Deal me three, and Surprise me again buttons centered
   along the bottom (same size as on mobile) and leave poster and text
   as they are; let these size to a maximum width of 1200, once over
   that put the column back on the right."

   So between 900 and 1199 Surprise keeps its PHONE presentation —
   poster full-bleed, text over it, the three buttons under the text —
   simply capped and centred (see the max-width rule further down).
   Nothing here is undone for that band; it just never applies.

   THE MATCHING HALF IS IN app.js: surSideColumn() builds the rail and
   moves the buttons into it, and it must ask the same question. It uses
   surWideMQ, which is this same 1200. Move one and you must move both.
   ============================================================ */
/* CLEARLY LANDSCAPE — 5:4, not 1:1, and the four pixels of difference
   matter more than they look.

   A media query measures the VIEWPORT, and a browser window is not its
   viewport: Safari's toolbar, bookmarks bar and tab strip took about
   155px off the top of Bart's. So a window 1837x2013 — plainly taller
   than it is wide, and he said so — presents a viewport of 1837x1858,
   and one of 1873x1972 presents 1873x1817, which is WIDER than it is
   tall. Both flipped to the two-column layout at a 1:1 test while
   looking portrait on his screen, and one of them landed in the
   contained-poster branch, which letterboxes: "the poster is not at the
   top ... it seems anchored to the bottom".

   Nothing was anchored to the bottom. The window was a hair landscape
   and the poster was being centred in a panel taller than itself.

   5:4 is the honest threshold anyway, chrome or no chrome: two columns
   need room for a text column AND a poster, and a window 3% wider than
   it is tall has no more of that than a square one. Below 5:4 the page
   stacks, which is what a nearly-square window wants.

   The rest of the rule is Bart's: "laptop mode (ie more width than
   height)" against "desktop mode (ie more height than width)" — a
   poster beside its text needs the room to be wide, and a window taller
   than it is wide has none however many pixels across it is. So above
   1200 a landscape window gets the two columns below, and a portrait one
   keeps the phone's arrangement — poster full width at the top, text
   underneath — with the settings column still on its right. That
   portrait layout needs no rules of its own: it is what the >=1200 block
   further up already lays out, and this block is what used to convert
   it into two columns whatever the shape. */
@media (min-width: 1200px) and (min-aspect-ratio: 5/4) {
  /* ---- Surprise, wide: EVERY NUMBER HERE IS STATIC ----
     Read this before changing anything below.

     The version this replaces had the poster pinned at the top with its
     height coming from the file, and then asked JS to measure where it
     ended so the text column and the bookmark could be hung off that.
     Every one of Bart's complaints came from that one decision:

       "the text column randomly jumps up or down"   the measured bottom
           is a different number for every film, and a different one
           again for the fallback while the picture is still loading.
       "the bookmark is inconsistently placed ... sometimes missing"
           same properties, same race; a poster that had not laid out yet
           left the previous film's values, or none.
       "the entire poster grows and shrinks now, instead of breathing
        within a fixed window"   the breathing scales the element, and
           once the element WAS the picture rather than sitting inside a
           fixed frame, the outer edges scaled with it.

     So: no custom properties, no measurement, no animation. The poster's
     top, bottom and right edges are the same on every film, which is
     what lets the text and the bookmark be positioned in plain CSS.

     WHY HEIGHT IS THE GIVEN DIMENSION NOW. The box still has to BE the
     picture — growTargetRect (app.js) flies a dealt card at this <img>'s
     layout box, and every past bug here was a box of a different shape
     from the picture (4.88.3 letterboxed a 2:3 inside a 3:5; 4.88.4 let
     a max-width clamp and squash it). A definite height with width:auto
     keeps that property exactly as a definite width with height:auto
     did — but it also fixes the top AND bottom edges, which the other
     way round could not.

     THE HEIGHT IS THE SMALLER OF TWO BOUNDS, and both are pure CSS:
       100% - 52px   the panel less the 26px above and below.
       0.78 x (100vw - rail - 300px)
                     the height at which a 2:3 poster is still only 52%
                     of the panel's width. The panel is the window less
                     the tab rail and the 300px settings column, so 52%
                     of it is 0.52 x that, and a 2:3 poster 1.5x that
                     wide is 0.78 x it. Written with var(--rail) rather
                     than the 484px it used to hard-code: the rail
                     widened to 220 and a baked-in constant would have
                     quietly started lying.
     Whichever binds, the poster fits vertically AND leaves the text
     column its room. Measured: 738px tall at 1440x790, 802 at 1512x982,
     637 at 1300x1180, 1028 at 1920x1080 — and the same expression is
     written into .sur-body's top, so the two centres agree by
     construction rather than by measurement. */
  /* THE FRAME. Everything static that used to be written onto the <img>
     lives here now: the wrapper holds the geometry and CLIPS, so the
     picture inside it can breathe without the poster's own edges moving.
     Width is deliberately not given — an absolutely positioned box with
     `width: auto` shrink-wraps its in-flow content, which is the poster,
     so the frame is exactly the picture's shape without anyone measuring
     anything. Its top, bottom and right edges are fixed, which is what
     lets .sur-body and .sur-actions be plain constants. */
  /* THE HEIGHT, NAMED ONCE. It was written out three times — on the
     frame, on the text column's top, and implied by the bookmark's —
     and three copies of a two-term min() is how they drift apart. The
     custom property is still a static expression: nothing measures it,
     nothing publishes it from JS, which is the rule this page was
     rebuilt around after 4.88.x. */
  .sur-panel {
    /* 100vh, NOT 100%, and this is load-bearing. A custom property holds
       an unresolved token: the `100%` this used to carry meant the
       panel's HEIGHT where the property was used as a height, and its
       WIDTH where the same property was used — through --sur-pw below —
       to work out a horizontal offset. Measured at 1800x860: the height
       bound resolved against 1280 instead of 860, the poster's assumed
       width came out 665 instead of 539, and the gap between the text
       and the poster opened to 199px while the pair stayed dutifully
       centred. The panel is `inset: 0` inside a view that is `inset: 0`
       from 900 up, so its height IS the viewport's, and 100vh says so
       in a unit that means the same thing in both axes. */
    --sur-h: min(calc(100vh - 52px), calc(0.78 * (100vw - var(--rail) - 300px)));
    /* THE POSTER'S WIDTH AT THE RATIO ALL BUT A FEW OF THEM ARE. The
       frame itself is `width: auto` and shrink-wraps the real picture —
       nothing here changes that — but CENTRING A PAIR needs the pair's
       width in advance, and only one of the two is a known quantity.
       2:3 is what Surprise deals: poster_clean, straight off TMDB.
       A poster wider than 2:3 grows leftward into the gap, which is why
       the gap below is generous rather than tight. */
    --sur-pw: calc(var(--sur-h) * 2 / 3);
    --sur-text: min(36%, 460px);
    --sur-gap: clamp(40px, 4vw, 88px);
    --sur-pair: calc(var(--sur-text) + var(--sur-gap) + var(--sur-pw));
    /* One number, and both columns hang off it: the distance from either
       edge of the panel to the pair. */
    --sur-side: calc(50% - var(--sur-pair) / 2);
  }
  .sur-panel .sur-art {
    display: block;
    position: absolute;
    /* CENTRED ON THE WINDOW, not pinned under the "Deal me three" line.
       Bart: "make the destination (for poster and the text box next to
       it) in laptop mode ... vertically centered to the window height".
       It was `top: 26px`, which put a short poster high and left the
       air below it.

       AS AN OFFSET, NOT A TRANSFORM. `top: 50%; translateY(-50%)` would
       centre it just as well and break the flight: surFrameTargetRect
       reads the frame's box to know where a dealt card should land, and
       offsetTop — the thing measureUntransformed exists to use — does
       not see transforms. Half the height subtracted from half the panel
       is the same place with a real box at it. */
    top: calc(50% - var(--sur-h) / 2);
    /* THE PAIR IS CENTRED, NOT SPLIT TO THE EDGES. The poster used to be
       `right: 4%` and the text `left: clamp(28px, 4vw, 64px)`, so every
       pixel a window gained went into the space BETWEEN them — Bart:
       "the poster and text box should stay together and be horizontally
       centered in the widening window, not get dragged apart". Now the
       gap is fixed and the pair as a whole is centred, so widening moves
       both of them and neither away from the other. */
    right: var(--sur-side);
    left: auto;
    bottom: auto;
    height: var(--sur-h);
    width: auto;
    /* A LANDSCAPE STILL IS THE ONE THING THIS CANNOT HOLD: the height
       bound is derived from a 2:3 shape, so a 16:9 image at that height
       would be wider than the panel. Surprise deals poster_clean, which
       is portrait, so in practice this never binds. */
    max-width: 62%;
    overflow: hidden;
    border-radius: 4px;
  }
  /* IN FLOW, so it gives the frame its width — and so its layout box is
     still exactly the picture, which is what growTargetRect flies a
     dealt card at. The standby half of the double buffer comes out of
     flow instead: two in-flow images would stack and double the frame's
     height. Exactly one of the pair carries .sur-standby at any moment
     (surSwapBuffers swaps the class synchronously), so exactly one is
     ever sizing the frame. */
  .sur-panel .sur-art .sur-bg {
    display: block;
    position: relative;
    /* ALL FOUR OFFSETS, explicitly. `right: 4%` was on this image while
       it was absolutely positioned, and on a RELATIVELY positioned one
       the same declaration means "shift 4% to the left" — measured, the
       poster's layout box came back 21px left of the frame, which is
       where a dealt card would then have flown. Leaving any of the four
       to be inherited from the rule this replaces is the same trap. */
    left: auto !important;
    right: auto !important;
    top: auto !important;
    bottom: auto !important;
    height: 100% !important;
    width: auto !important;
    margin: 0 !important;
    z-index: 0;
    -webkit-mask-image: none !important;
            mask-image: none !important;
  }
  .sur-panel .sur-art .sur-bg.sur-standby {
    position: absolute;
    inset: 0;
    width: 100% !important;
    height: 100% !important;
    object-fit: cover;
  }
  /* AND NOW IT CAN BREATHE — with the app's OWN zoom, not a second one.
     The frame clips, so bb-poster-zoom scales the picture inside a
     window that does not move, which is exactly what it did on a phone
     all along, where .sur-panel was the window. 4.88.9 removed the
     movement because without a frame it resized the whole poster; the
     answer was the frame, not losing the movement.

     So there is deliberately NO rule here. An earlier build declared its
     own keyframes at 14s and a flat 1.06, and the poster then breathed
     at a different speed and depth from the same poster in desktop mode
     — Bart: "at a very different speed and zoom level". bb-poster-zoom
     is 9s and its depth is per-poster (--kb-max, set by
     applyFaceFraming from how far off-centre the face is), and
     transform-origin comes from the same measurement. Overriding either
     is how the two modes drift apart. Leave them alone. */
  /* CENTRED ON THE POSTER'S CENTRE, in CSS, by repeating the frame's own
     height expression: the frame's centre is 26px + half its height, and
     this box's own middle is put on exactly that line. Bart: "put the
     vertical centre of the text box on the same height as the vertical
     centre of the poster". Nothing measured, so nothing to jump.
     36% pairs with the frame's 52%-equivalent width: 4 + 36 + 52 + 4 =
     96% of the panel, so the columns cannot meet even when both bounds
     bind at once. */
  .sur-panel .sur-body {
    left: var(--sur-side) !important;
    right: auto !important;
    width: var(--sur-text) !important;
    /* The window's middle, which is now also the poster's middle — so
       the two centres agree by construction rather than by repeating an
       expression. A transform is fine HERE: nothing flies at the text. */
    top: 50% !important;
    bottom: auto !important;
    transform: translateY(-50%) !important;
  }
  /* The bookmark sits in the poster's own top-right corner — and can be
     written as a constant because the frame's top and right edges are
     constants. This is the rule that used to need JS. */
  /* The bookmark sits in the poster's own top-right corner, so it
     follows the frame's top rather than the panel's. Still a constant —
     the same expression the frame uses, plus its 10px inset. */
  .sur-panel .sur-actions {
    top: calc(50% - var(--sur-h) / 2 + 10px) !important;
    right: calc(var(--sur-side) + 10px) !important;
  }
  .sur-panel .sur-blur { opacity: 0.32 !important; }
}

/* One line on a phone, two in the narrow column. No max-width can do
   this — the column is ~154px, so every width that fits "something to
   watch?" also fits "Looking for something". */
/* The break only makes sense inside the narrow side panel, and the panel
   only exists in the wide band — so it follows the block's floor, not
   the desktop breakpoint. */
/* THE PANEL'S OWN BREAKPOINT, IN TWO PLACES THAT MUST AGREE.
   Below 1100 there is no explore column, so: the heading's line break
   has no narrow column to break for, and the grid must stop reserving a
   track for it. Setting --bb-panel to 0 does the second job twice over —
   the column collapses and #feed-arrow-r, whose offset is calc() off the
   same variable, comes back to the window edge. */
@media (max-width: 1099px) {
  .bb-wide-br { display: none; }
  #view-home { --bb-panel: 0px; }
}
/* The second half of the landscape rule above: the column is hidden on a
   window taller than it is wide, and the grid has to stop reserving its
   track. Written as its own query rather than folded into the one above,
   because the two conditions are ORed — either being too narrow or being
   too tall takes the column away — and a media query cannot say "or".

   4999/4000 rather than 5/4, so the two queries cannot both match. Both
   bounds are inclusive, so a window at exactly 1.25 would otherwise be
   told to show the column AND to collapse the track it lives in: a panel
   squeezed into nothing. The exact ratio belongs to the landscape rule
   above. */
@media (max-aspect-ratio: 4999/4000) {
  .bb-wide-br { display: none; }
  #view-home { --bb-panel: 0px; }
}

/* Collections hero — the exception, at the DESKTOP breakpoint.
   Everything else above is the wide band only, but Bart asked for this
   one across modes: "the top landscape rail should be the same height as
   the lower landscape rails - also in desktop and phone mode". The card
   is 16:9, so its height is decided by its width; at flex-basis 100% a
   1440px window gave a 664px hero (84% of the screen) and the first rail
   below it was never reached. Capping the basis caps the height, and 560
   is the width the rails beneath already use. */
@media (min-width: 900px) {
  /* THE CARD KEEPS ITS SIZE; THE ROW GETS A SECOND COLUMN. Stretching the
     picture to fill the row was the obvious move and the wrong one —
     Bart: "stretching on desktop/laptop is no good - I'd rather put
     title, rating and excerpt next to it to use the empty space". So the
     track is pinned to exactly the width the card had (the 8px is the
     track's own padding, so the picture still measures 560 / 45% and
     still matches the landscape rails below it) and the words take the
     rest. */
  /* ONE CARD AROUND BOTH. Bart: "the image should also be part of the
     card - and the image should NOT get smaller than it currently is -
     so the card needs to go around it". So the line and the ground move
     off the words and onto the row itself, and the picture sits flush
     inside it — no padding on that side, which is also what keeps it the
     size it was rather than losing the card's padding out of its width.
     The row clips to its own corner, so the picture needs no radius of
     its own; giving it one as well left a hairline of ground showing
     through at each corner. */
  /* ═══ THE PICTURE'S HEIGHT IS ITS OWN BUSINESS ═══
     align-items was `stretch`, which made the picture's height the ROW's
     height — and the row's height is decided by whichever child is
     taller. The picture's real height comes from its 16:9 ratio, but a
     stretched box has its height imposed, so the ratio had nothing to
     say: Chrome resolved the circle correctly on a fresh layout and, on
     a resize, from the text instead. Bart, 19 Sep 2026: "the picture
     just resizes to a lesser height after resizing the window" — 855
     wide by 310 tall in his screenshot, a 2.76 ratio where 16:9 wants
     481. A reload fixed it because a fresh layout resolves it the other
     way round.
     `center` takes the imposition away: the picture is as tall as its
     own width says, the words are centred against it, and the row is as
     tall as the taller of the two. Nothing has to be resolved twice. */
  .hero-wrap {
    position: relative;
    display: flex;
    align-items: center;
    gap: 0;
    /* More room to breathe than the phone's 8px, because there is more
       card here. The line, the ground, the aura and the picture's
       hairline are all on .hero-wrap / .hero-card in the base now, shared
       with the phone — see the note there. */
    padding: 18px;
  }
  /* The picture's copy of the mark is hidden and the card's own is shown
     in its place — the card is clipped, so the pill cannot be positioned
     out of it. */
  .hero-wrap .hero-card .mk-pill { display: none; }
  #hero-pill { display: block; }
  /* THE WIDTH GOES ON WHATEVER IS ACTUALLY THE FLEX ITEM. On this layout
     addRailArrows wraps every row — the hero included — in a .rail-wrap
     so the scroll arrows have something to sit against, so .hero-track is
     a GRANDCHILD here and sizing it did nothing: the wrapper shrink-wrapped
     to its own padding and the card came out 4x2 (measured). Both
     spellings are listed because the wrapper only appears once the JS has
     run. */
  /* 45% OF THE ROW, NOT OF WHAT THE PADDING LEAVES. The picture has to
     stay the size it was — Bart: "the image should NOT get smaller than
     it currently is" — and a plain 45% would now measure against the
     padded content box and lose 45% of the padding out of its width. The
     17.1px puts back exactly what the padding and border take (0.45 x
     38), so this is 45% of the row's outer width at every window size,
     which is what keeps it level with the landscape rails below. */
  .hero-wrap > .rail-wrap,
  .hero-wrap > .hero-track { flex: 0 0 auto; width: calc(45% + 17.1px); max-width: 560px; }
  .hero-wrap > .rail-wrap > .hero-track { width: 100%; max-width: none; }
  /* The track's own 2px/4px would read as a second, uneven inset inside
     the card's 18px. */
  .hero-wrap .hero-track { padding: 0; }
  /* ═══ NOTHING HERE IS ALLOWED TO BE CIRCULAR ═══
     The picture's height used to be worked out backwards: the card is
     16:9, the card is stretched to the track, the track's height comes
     from its content, and its content is the card. Every engine has to
     break that circle somewhere, and Safari broke it differently after a
     resize than on a fresh layout — Bart's screenshots, 19 Sep 2026, show
     the picture coming back short, the card with it, and the excerpt
     refitted to the smaller space. Chromium resolved it correctly both
     times, which is why it took his Safari screenshots to see it.
     So the ratio goes on the TRACK, whose width is already definite
     (45% of the row), and the card simply fills it. Width in, height out,
     once, in one direction. layoutHeroSide enforces the same number in
     JS as a backstop, since this cannot be tested here. */
  .hero-wrap .hero-track { aspect-ratio: 16 / 9; }
  .hero-wrap .hero-card { height: 100%; }
  /* The scrim is for text over artwork, and the text is next door now. */
  .hero-card::after { background: none; }
  .hero-card { flex: 0 0 100%; }
  /* The overlay would only repeat what is now printed next to it, and it
     sits over the part of the frame worth looking at. */
  .hero-body { display: none; }
  .hero-card::after { background: linear-gradient(180deg, rgba(10, 9, 7, 0) 60%, rgba(10, 9, 7, 0.35) 100%); }
  /* The words fill the rest of the card. The line and the ground it wore
     briefly (5.16.9) are on .hero-wrap now — a card inside a card is not
     what was asked for. The line and ground themselves are .sug-card's, a
     Discovery Pick's pitch, copied rather than shared because that one
     lives in the review overlay and carries an avatar row's layout with
     it; if it changes, change these with it. */
  .hero-side {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    justify-content: center;
    gap: 10px;
    min-width: 0;
    overflow: hidden;
    padding: 0 6px 0 26px;
    cursor: pointer;
  }
  .hero-side-title {
    font-family: var(--font-display);
    font-weight: 700;
    /* Room to be the page's headline here, where the overlay version had
       to fit inside a corner of the picture. */
    font-size: clamp(28px, 2.6vw, 40px);
    line-height: 1.02;
    letter-spacing: 0.3px;
    color: var(--fg);
  }
  .hero-side:hover .hero-side-title { color: var(--accent); }
  .hero-side-meta { display: flex; align-items: center; gap: 9px; }
  .hero-side-tagline {
    font-style: italic;
    font-size: 15px;
    color: var(--fg2);
  }
  .hero-side-text {
    font-size: 15px;
    line-height: 1.6;
    color: var(--fg2);
    /* BOUNDED BY THE PICTURE, in whole sentences — layoutHeroSide measures
       what is left after the title, rating and tagline and sets the
       max-height, then drops one sentence at a time until the text fits
       inside it. No line clamp by default: the point is that it should
       not be cut. The clamp below is the fallback for a first sentence
       too long to fit at all, and app.js sets the line count to match the
       same measured budget. */
    overflow: hidden;
  }
  .hero-side-text.is-cut {
    display: -webkit-box;
    -webkit-box-orient: vertical;
  }
}
/* BB-LAPTOP:END */

/* The name in "The Daily Actor Reel: NAME" and the Director Draw — part
   of the heading, and a way through to that person's page (5.16.0). */
.rail-head h2 .draw-who {
  font: inherit;
  /* A button is not ordinary text: the UA sheet states letter-spacing,
     word-spacing and text-transform on it, so they never reach it from
     the heading, and the `font` shorthand does not carry them either.
     Measured: the heading was 2.4px/uppercase and the name inside it
     normal/none — the same typeface reading as a different one. */
  letter-spacing: inherit;
  word-spacing: inherit;
  text-transform: inherit;
  line-height: inherit;
  color: var(--accent);
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
}
.rail-head h2 .draw-who:hover { text-decoration: underline; }

/* ═══════════════ THE HOME HERO, AS A CARD (wide layout) ═══════════════
   Bart, 21 Sep 2026, in three steps on one afternoon:
     1. "I don't like how the main poster is less attention drawing than
        on mobile... Maybe try making that whole section into a card as
        well (with outline, aura etc)?"
     2. "fix the height of the poster so it always resizes with the
        height of the card - so on desktop mode it looks more like the
        mobile version - large poster with text at the bottom"
     3. "maybe try the horizontal hero image on the Homepage instead of
        the poster? The poster is already in the rail below, and the
        landscape hero might fit better as card-filling image with text
        underneath"

   Where that lands: one landscape card, the still filling its width, the
   words in a band underneath, and the card only as tall as those two.

   WHY THE PICTURE CHANGED. Steps 1 and 2 kept the poster and spent the
   afternoon fighting its shape — a portrait picture in a landscape box
   can only be shown whole with blurred fill down both sides, and the
   deeper the card, the more of it is fill. Step 3 gives the box a
   picture the same shape as itself. Every film in the feed already
   carries the still, with its own eye measurement and authored end
   frame; see feedArtOf in app.js, and .feed-art-poster below for the
   films that have no landscape art at all.

   WHY THE CARD IS AN ELEMENT AND NOT A PSEUDO-ELEMENT ANY MORE. It was
   .feed-slide::before while the card was the whole slide. The card now
   HUGS its contents, and the slide cannot: it is the thing the feed
   snaps to, so it has to stay exactly one screen wide and the full
   height of the row. So the card is .feed-card, a wrapper that is
   display:contents on the phone — the same no-op trick .feed-art has
   always used — and a real box here. */
@media (min-width: 900px) {
  /* The slide is now only a place to centre the card in. */
  .feed-slide {
    display: flex;
    align-items: center;
    justify-content: center;
    /* ═══ ROOM FOR THE GLOW, WHICH IS WHAT WAS ACTUALLY MISSING ═══
       32, not 14. The aura reaches 28px and the card was 14px inside this
       box, so half of it had nowhere to go — and the thing cutting it off
       is not this element but .feed, which is a scroller and therefore
       clips everything at the row's edges. Making the slide
       overflow:visible (below) was necessary and not sufficient: the glow
       escaped the slide and hit the scroller's clip one pixel later. The
       only way to see a 28px glow is to leave 28px of room inside the
       scroller, which is this padding. The two feed arrows move out with
       it — see .feed-arrow-l/r — so they stay inside the card's line. */
    padding: 32px;
    gap: 0;
    /* ═══ AND THE GLOW HAS TO BE ALLOWED OUT ═══
       Bart, 21 Sep 2026: "the aura seems more pronounced on the
       collections here, but not on the homepage?" Both carry the same
       two shadows; the difference was this box. The slide clips (for the
       phone, where the Ken Burns zoom grows past the screen), the card
       sits 14px inside it, and the glow reaches 28 — so more than half
       of it was being cut off at the slide's edge. The Editor's Pick has
       no clipping ancestor, so all of its glow shows. Same CSS, two
       different amounts of it visible.
       Nothing needs the clip on THIS layout: the zoom is clipped by
       .feed-art, which has its own overflow:hidden, and .feed itself is
       a scroller and so clips everything at the row's edges anyway. What
       escapes is a soft warm glow crossing into the neighbouring slide,
       which is off screen — and its glow into this one, in the same
       place and the same colour. */
    overflow: visible;
  }
  .feed-card {
    display: flex;
    flex-direction: column;
    flex: 1 1 auto;
    /* Never taller than the row it is centred in; when it would be, the
       picture gives the height back (flex: 0 1 auto on .feed-art) and
       object-fit:cover trims the still rather than squashing it. */
    max-height: 100%;
    min-height: 0;
    padding: 18px;
    border: 1px solid var(--accent);
    border-radius: 16px;
    /* The catalogue card's ground and the Editor's Pick's aura — the same
       two values as .hero-wrap in the base. Change them together. */
    background: linear-gradient(135deg, rgba(201, 132, 58, 0.16), #1c1913);
    /* Two layers, the same pair as .hero-wrap in the base — see the
       note there for why one shadow could not simply be widened. */
    box-shadow:
      0 0 14px -4px rgba(201, 132, 58, 0.45),
      0 0 38px -10px rgba(201, 132, 58, 0.72);
  }
  .feed-art {
    position: relative;
    display: block;
    flex: 0 1 auto;
    width: 100%;
    height: auto;
    max-width: none;
    max-height: none;
    min-height: 0;
    /* The still's own shape. It is a PREFERENCE, not a promise: a short
       window shrinks this box and the picture is cropped top and bottom,
       which is what the review page's hero band does too. */
    aspect-ratio: 16 / 9;
    overflow: hidden;
    border-radius: 10px;
    box-shadow: 0 18px 40px rgba(0, 0, 0, 0.55);
  }
  /* THE SAME HAIRLINE THE EDITOR'S PICK CARRIES — and it was missing
     here entirely. Bart: "can you put the same hairline on the homepage
     hero image?" It was in the stylesheet and never reached the screen:
     an outline at offset -1px is painted before positioned descendants,
     and .feed-img is absolutely positioned at inset 0 with object-fit
     cover, so the picture covered the ring on all four sides. Measured
     across the panel's edges: no line anywhere, the card's ground
     meeting the picture directly. (On the Editor's Pick the picture is
     z-index:-1, which is why the same CSS showed there.)
     Drawn as a layer of its own, above the picture, so paint order
     cannot take it away again. */
  .feed-art::after {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    /* The same two rings as .hero-card::after in the base. */
    box-shadow:
      inset 0 0 0 1px rgba(232, 192, 101, 0.55),
      inset 0 0 0 2px rgba(0, 0, 0, 0.38);
    pointer-events: none;
  }
  .feed-slide .feed-img {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    /* COVER, not contain: the picture and the frame are the same shape,
       so nothing is cropped until the row is too short for both the
       picture and the words — and cover is also the branch
       applyFaceFraming was written for, the one the review page's hero
       has always used. */
    object-fit: cover;
    z-index: auto;
    -webkit-mask-image: none;
    mask-image: none;
  }
  /* Nothing to fill: the picture fills its frame. */
  .feed-slide .feed-blur { display: none; }

  /* THE FILMS WITH NO LANDSCAPE ART. backdrop_is_poster says the
     "backdrop" is really the texty poster, so there is nothing to crop
     into a 16:9 slice. Those keep the poster, shown whole against its own
     blur — the arrangement the rest of this card used to have. */
  .feed-art-poster .feed-img { object-fit: contain; }
  .feed-slide .feed-art-poster .feed-blur {
    display: block;
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    z-index: -2;
  }

  /* THE WORDS, UNDERNEATH. In flow on the card's own ground, so they need
     none of the phone's shading and none of its absolute placement — the
     two rules this layout used to spend undoing each other. */
  .feed-text {
    position: static;
    flex: 0 0 auto;
    align-self: stretch;
    max-width: none;
    max-height: none;
    overflow: visible;
    padding: 14px 6px 2px;
    background: none;
    gap: 7px;
  }
  /* A card two-thirds of a wide screen across would otherwise set the
     excerpt in one very long line. */
  .feed-text > * { max-width: 72ch; }
  .feed-title { font-size: clamp(30px, 2.6vw, 44px); }
}


/* ═══════════ 5.20.8: SURPRISE WITHOUT SETTINGS, AND A HAND OF CARDS ═══════════
   Bart: "hide the 'settings' in 'Surprise Me' - they don't add enough
   value as it is not how the library is organised at the moment. On
   mobile just hide the button, on desktop/laptop maybe reuse the right
   hand column for playing cards - line up (playfully) a few below each
   other and a visitor can pick one which will then travel and open on
   the left. Still leave the 'deal me three' and 'surprise me again'
   buttons as they are." */
.sur-settings-btn { display: none !important; }
/* Two buttons now share the phone's row: each takes half. */
.sur-btns .btn-outline { flex: 1; justify-content: center; }

@media (min-width: 900px) {
  .sur-rail .sur-sheet { display: none; }
  .sur-picks-k {
    margin-top: 6px;
    font-size: 10.5px;
    font-weight: 700;
    letter-spacing: 0.14em;
    text-transform: uppercase;
    color: var(--dim);
    text-align: center;
  }
  /* The column scrolls; the hand does not need to — it is sized to fit
     what is left under the buttons (surPicksGeometry). */
  .sur-rail { overflow: hidden; }
  .sur-picks { position: relative; flex: 1 1 auto; min-height: 0; margin: 0 -4px; }
}
.sur-pick {
  position: absolute;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
  border-radius: 12px;
  transform: rotate(var(--tilt, 0deg));
  transition: transform 0.22s ease, filter 0.22s ease;
}
.sur-pick .deal-face { will-change: auto; }
/* Pulled half out of the pile toward the poster, straightened. */
.sur-pick:hover,
.sur-pick:focus-visible {
  transform: translateX(-16px) rotate(0deg) scale(1.04);
  z-index: 20 !important;
  outline: none;
  filter: drop-shadow(0 0 14px rgba(201, 132, 58, 0.45));
}
.sur-pick-new { animation: sur-pick-in 0.5s cubic-bezier(0.2, 0.8, 0.25, 1) both; }
@keyframes sur-pick-in {
  from { transform: translate(40px, -60px) rotate(18deg) scale(0.7); opacity: 0; }
  to   { transform: rotate(var(--tilt, 0deg)); opacity: 1; }
}
/* The picked card (5.20.11) is a Deal me three card — .deal-card, with
   .flipped, .turning and .picked doing exactly what they do there — lifted
   out of the column into a fixed layer of its own so it can cross the
   page. Only what that move needs is said here; the turn, the grow and
   their timings are the deal's own rules above. */
.deal-card.sur-fly {
  position: fixed;
  z-index: 60;
  pointer-events: none;
  /* .deal-cards gives a dealt card its depth; this one has no such
     parent, so it carries its own. */
  perspective: 1200px;
  /* The lift before the turn. .picked replaces this with the deal's own
     1.1s grow. */
  transition: transform 0.45s cubic-bezier(0.2, 0.8, 0.25, 1);
}
/* The column card's back, reel and all, painted on the FRONT face — the
   face a .flipped card shows — so the real back is free for revealArt.
   Flat and still: an animated reel gets a layer of its own that escapes
   backface culling, and would show through the poster on every pass. */
.sur-fly-cover {
  position: absolute;
  inset: 0;
  will-change: auto;
  backface-visibility: visible;
  -webkit-backface-visibility: visible;
}
.sur-fly .deal-reel { animation: none; }
/* The grow is the deal's, and has to out-rank the lift above. */
.deal-card.picked.sur-fly { transition: transform 1.1s cubic-bezier(0.32, 0, 0.2, 1); }
.deal-card.picked.sur-fly-gone {
  opacity: 0;
  transition: transform 1.1s cubic-bezier(0.32, 0, 0.2, 1), opacity 0.26s ease;
}
@media (prefers-reduced-motion: reduce) {
  .sur-pick, .deal-card.sur-fly { transition: none; }
  .sur-pick-new { animation: none; }
}


/* ═══════════ THE PERSON CARD (5.20.12) ═══════════
   First in the Daily Actor Reel and the Daily Spotlight: the day's person,
   poster-sized, so the row reads as "this person, and their films". It
   takes every size rule a poster card has (it IS one, plus this class);
   what marks it out is the gold — a rim round the portrait and the role
   in gold in the strip where a poster says Review. */
.person-thumb { box-shadow: inset 0 0 0 1.5px rgba(201, 132, 58, 0.75); }
.person-thumb img { object-position: 50% 20%; }
/* The rim is painted by the frame, so it has to sit over the picture. */
.person-thumb::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: inset 0 0 0 1.5px rgba(201, 132, 58, 0.75);
  pointer-events: none;
}
.person-strip {
  color: var(--accent);
  border-color: rgba(201, 132, 58, 0.45);
  background: #221b11;
}
.person-card { cursor: pointer; }
.person-more {
  margin-top: 3px;
  font-size: 11px;
  font-weight: 600;
  color: var(--accent);
}


/* ═══════════ A PERSON'S SHORT BIO (5.20.14) ═══════════
   Under the name and the role line on an actor's or crew member's page —
   centred, like everything else in that head. It arrives a moment after
   the page, so it fades in rather than landing. The reviewer's page has
   its own bio in the same element and its own layout; none of this
   touches it. */
.person-overlay:not(.pv-author) .pv-bio {
  margin: -4px auto 18px;
  max-width: 58ch;
  text-align: center;
  font-size: 14px;
  line-height: 1.6;
  opacity: 0;
  transition: opacity 0.4s ease;
}
.person-overlay:not(.pv-author) .pv-bio.pv-bio-in { opacity: 1; }
.pv-bio-src {
  display: block;
  margin-top: 6px;
  font-size: 11px;
  color: var(--dim);
}


/* The official poster popup (5.20.22): the stills' popup, portrait. A
   FIXED 2:3 BOX — the shape of a poster — as tall as the window allows,
   or as 92% of the width allows, whichever is less. Fixed, so the card-
   sized picture shown first and the full file that replaces it occupy
   exactly the same box and nothing jumps when the sharp one lands; an
   odd-shaped poster is letterboxed inside it rather than resizing it.
   .po-card, NOT .poster-card (5.20.24): that name already belongs to
   every poster in every rail, and 5.20.22 blew them all up to this. */
.po-card {
  width: auto;
  max-width: none;
  flex: 0 0 auto;
  height: min(calc(100vh - 80px), calc((100vw - 28px) * 1.5), 960px);
  height: min(calc(100dvh - 80px), calc((100vw - 28px) * 1.5), 960px);
  aspect-ratio: 2 / 3;
  background: #000;
}
.po-card img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
  cursor: zoom-out;
}

/* THE TRAILER AND THE STILLS SCALE WITH THE WINDOW TOO (5.20.25). Bart,
   after the poster: "the poster scales with the browser window size
   which is good - can the movie stills and trailer do the same?" They
   were capped at 620px wide whatever the window. Now the card is the
   largest 16:9 box that fits: the full width less the gutters, or as
   wide as the window's height allows (less room for the page edge),
   whichever is less — and no wider than 1600, past which a trailer is
   a cinema screen rather than a popup. The player and the stills strip
   are already 16:9 at 100% of the card, so they follow it. */
.trailer-overlay .popup-card,
.stills-overlay .popup-card {
  max-width: none;
  width: min(calc(100vw - 2 * var(--pop-gx)), calc((100vh - 2 * var(--pop-gy)) * 16 / 9), 1600px);
  width: min(calc(100vw - 2 * var(--pop-gx)), calc((100dvh - 2 * var(--pop-gy)) * 16 / 9), 1600px);
}

/* ═══ ROOM AT THE SIDES, AND THE CARD'S LINE AND AURA (5.20.26) ═══
   Bart: "there should be more padding on the right and left of the pop
   up window - should not look like it is almost touching the sides.
   Also, on all three items (poster, stills, trailer), could you add the
   hairline and aura like it is around the 'Latest' Hero on the home
   page."
   THE GUTTERS are one pair of numbers the three sizes are all worked
   out from. 14px a side was the phone's number used everywhere; now it
   grows with the window — 24px on a phone, 6% of the width on a laptop
   (86px at 1440), never more than 96px — and 48px top and bottom, which
   also leaves the aura's 28px of reach room to show.
   THE LINE AND THE AURA are .feed-card's own two values: the gold line
   (--accent) and the same two-layer glow — see .hero-wrap in the base,
   where they are shared from. The dark drop shadow the card wore
   before is gone, because under a glow it only muddied it. */
.popup-overlay {
  --pop-gx: clamp(24px, 6vw, 96px);
  --pop-gy: 48px;
  padding: var(--pop-gy) var(--pop-gx);
}
.popup-card {
  border-color: var(--accent);
  box-shadow:
    0 0 14px -4px rgba(201, 132, 58, 0.45),
    0 0 38px -10px rgba(201, 132, 58, 0.72);
}
.po-card {
  height: min(calc(100vh - 2 * var(--pop-gy)), calc((100vw - 2 * var(--pop-gx)) * 1.5), 960px);
  height: min(calc(100dvh - 2 * var(--pop-gy)), calc((100vw - 2 * var(--pop-gx)) * 1.5), 960px);
}

/* ═══ THE PICK'S HAIRLINE, DRAWN WHERE NOTHING CAN CLIP IT (5.20.26) ═══
   Bart, 30 Sep 2026, with a screenshot: the line along the foot of the
   Editor's Pick picture on Collections had gone again, the corners and
   the other three sides still there. Not the scrim this time (that was
   21 Sep, and it still draws the ring above the picture). Read off his
   screenshot: along the bottom, the DARK inner ring is the last row
   showing, and the gold ring outside it is missing, while the corners'
   curves are whole. So the ring was drawn, and its last pixel and a half
   was cut off by something ABOVE the card: the track, which is a
   sideways scroller and so clips at its own box, came out a fraction
   shorter than the card in his browser. Chromium sizes the two the same
   at every width (swept 920-1900 here, and on the live site), which is
   why it only showed on his screen.
   So on this layout the ring moves out of the clip: it is drawn by the
   .rail-wrap round the track, whose box IS the track's box and which
   clips nothing. Whatever height the engine gives the card, the line is
   drawn at the edge of what is actually shown. The card's own ring is
   switched off here so the two cannot double up in an engine that gets
   it right. The phone layout has no wrapper and keeps the card's ring. */
@media (min-width: 900px) {
  .hero-wrap > .rail-wrap::after {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: 16px;
    /* The same two rings as .hero-card::after in the base. */
    box-shadow:
      inset 0 0 0 1px rgba(232, 192, 101, 0.55),
      inset 0 0 0 2px rgba(0, 0, 0, 0.38);
    pointer-events: none;
    z-index: 2;
  }
  .hero-wrap > .rail-wrap .hero-card::after { box-shadow: none; }
}

/* ═══ A RING THE PICTURE CANNOT SWALLOW (5.20.31) ═══
   Bart, on Camp Miasma's Poster: "does not seem to have the same aura
   around it as the other posters". It has the same line and glow — one
   rule for every popup — but the poster is cream, and a 1px gold border
   against cream all but vanishes, which takes the glow's edge with it.
   The Editor's Pick had exactly this on '71's pale pavement (see
   .hero-card::after): the cure there was a second, dark pixel just
   inside the gold, so the line is separated from the picture whatever
   the picture is. The same two rings here, drawn ABOVE the picture,
   trailer and stills included. */
.popup-card::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow:
    inset 0 0 0 1px rgba(232, 192, 101, 0.55),
    inset 0 0 0 2px rgba(0, 0, 0, 0.38);
  pointer-events: none;
  z-index: 3;
}

/* ═══ SURPRISE'S TWO BUTTONS ARE ICONS (5.20.40) ═══
   Bart: "In Surprise, can you change the buttons 'surprise me again': a
   die like on the world explorer; 'deal me three' an icon with three
   cards". The World Explorer's gold disc, twice, side by side: a fanned
   hand of three cards (the front one dark, with the reel's gold hub) and
   the die. Their words stay as the accessible name and the hover title.
   On a wide screen they sit in a row at the top of the right-hand column,
   over "or pick a card"; on a phone, centred where the two worded
   buttons were. */
.sur-btns { justify-content: center; }
/* THE BRUSHED-GOLD LOOK (5.20.41)
   Bart: the gold discs were "too garish ... no aura. I like B, linked to
   the logo. The pictures within should have a thinner line/dots ... the
   same colour as the internal reel/B of the logo" — and of the mock-ups,
   "B3, but I think the lines of the pictures can be thinner still".
   So: the logo reel's own disc — its dark hub, a warm radial — inside a
   thin ring of the reel's metal, no shadow and no glow; the pictures are
   drawn in that same metal (the gradient lives in each SVG) with a fine
   line, and fill nearly two thirds of the disc. On hover the ring warms
   a little and the picture turns a few degrees; nothing glows. */
.sur-icon-btn {
  display: grid;
  place-items: center;
  flex: none;
  width: 52px;
  height: 52px;
  padding: 0;
  border: 1.5px solid transparent;
  border-radius: 50%;
  background:
    radial-gradient(circle at 35% 28%, #1e1710, #050302 80%) padding-box,
    linear-gradient(135deg, #fff3c9, #e8c065 35%, #8a5f18) border-box;
  cursor: pointer;
  transition: transform 0.15s ease, filter 0.15s ease;
}
.sur-icon-btn svg {
  width: 34px;
  height: 34px;
  stroke-width: 0.8;
  stroke-linejoin: round;
  stroke-linecap: round;
  transition: transform 0.2s ease;
}
/* The hand: the front card is filled with the disc's dark, so it hides
   the two cards behind it. */
.sur-icon-btn .sur-ic-front { fill: #0d0a07; }
.sur-icon-btn:active { transform: scale(0.95); }
@media (hover: hover) and (pointer: fine) {
  .sur-icon-btn:hover { filter: brightness(1.15); }
  .sur-icon-btn:hover svg { transform: rotate(-8deg); }
}
.sur-icon-btn:focus-visible { outline: 2px solid var(--fg); outline-offset: 3px; }
@media (max-width: 899px) {
  .sur-btns { gap: 28px; }
}
@media (min-width: 900px) {
  .sur-rail .sur-btns { flex-direction: row; justify-content: center; gap: 22px; }
  .sur-rail .sur-icon-btn { width: 56px; height: 56px; }
  .sur-rail .sur-icon-btn svg { width: 36px; height: 36px; }
}

/* ═══ LATEST CARRIES DISCOVERY PICKS TOO (5.20.43, restyled 5.20.44) ═══
   Each slide says which kind it is. Bart: "the text is not vertically
   centered in the pill ... make the pill / label look the same as the one
   of 'series', and put it on the same line as 'series'". So the kind is a
   .type-badge itself — the Series badge's gold block, size and type — in
   one row with Series (when it is one) and the gold New tag. Line-height
   1 and equal top and bottom padding, which is what centres the Series
   word; the old pill's 4px/3px split with a 1-line box was what sat its
   text high. */
.feed-tags {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 6px;
  margin-bottom: 2px;
}
/* One height for all three — the Series badge's, which its icon sets —
   so the row reads as a row and every word sits in the middle of its
   block. */
.feed-text .feed-tags .type-badge,
.feed-tags .feed-new { margin: 0; align-self: auto; height: 17px; box-sizing: border-box; padding-top: 0; padding-bottom: 0; }
.feed-tags .feed-new {
  align-self: auto;
  margin: 0;
  display: inline-flex;
  align-items: center;
  font-family: "Oswald", sans-serif;
  font-size: 9px;
  font-weight: 600;
  letter-spacing: 0.09em;
  line-height: 1;
  padding: 3px 6px;
  border-radius: 4px;
}
/* The desktop row of posters under the hero: the strip joins the picture
   as it does in Collections, so the picture's lower corners go square and
   the ring for the film on show stays inside the picture. */
.feed-strip button .mk-strip { flex: none; }

/* ═══ THE LATEST HERO, LAID OUT LIKE THE PHONE'S (5.20.44) ═══
   Bart, on a laptop: "the homepage hero image now looks terrible. Maybe
   it should work more like the mobile version - show the image from the
   top down and overlay the text from the bottom up (but still use the
   hero image ... not the poster)".
   Measured before this: the card hugged a 16:9 picture with the words in
   a band underneath, and at laptop heights the words won — at 1280x720
   the picture was 82px tall under 309px of text. Now the card fills the
   row, the hero still (the landscape picture, as before — the poster
   stays in the row below) fills the card from the top, and the words sit
   OVER its lower part on the same dark shading the phone uses, so the
   picture never gives up its height to them. The gold line, the ground
   and the aura are the card's as before. */
@media (min-width: 900px) {
  .feed-card {
    position: relative;
    align-self: stretch;
    height: 100%;
    max-height: 100%;
  }
  .feed-art {
    flex: 1 1 auto;
    aspect-ratio: auto;
    height: auto;
    min-height: 0;
  }
  .feed-text {
    position: absolute;
    /* Inside the card's 18px padding, i.e. exactly over the picture. */
    left: 18px;
    right: 18px;
    bottom: 18px;
    z-index: 2;
    max-height: calc(100% - 36px);
    overflow: hidden;
    padding: 84px clamp(40px, 3.4vw, 52px) clamp(20px, 2.2vh, 30px);
    border-radius: 0 0 10px 10px;
    /* The phone's shading, from the foot up: dark enough under the words
       to read on any picture, gone well before the top of the frame. */
    background: linear-gradient(to top,
      rgba(8, 7, 5, 0.94) 0%,
      rgba(8, 7, 5, 0.82) 38%,
      rgba(8, 7, 5, 0.45) 70%,
      rgba(8, 7, 5, 0) 100%);
    pointer-events: none;
  }
  .feed-text > * { max-width: 62ch; }
  /* A short card (a laptop's 720-800px window) keeps more of the picture:
     the excerpt stops at two lines there instead of taking the frame. */
  .feed-card { container-type: size; }
  @container (max-height: 520px) {
    .feed-text { padding-top: 64px; gap: 6px; }
    .feed-text .feed-review-text {
      display: -webkit-box;
      -webkit-line-clamp: 2;
      -webkit-box-orient: vertical;
      overflow: hidden;
    }
    .feed-text .feed-tagline { display: none; }
  }
  .feed-text .btn-primary,
  .feed-text .wl-btn,
  .feed-text a { pointer-events: auto; }
}

/* "Discovery Pick" is the longest marker, and the row's posters are only
   84px wide on a 720px-tall window: the strip's words shrink with the
   poster rather than pushing that one poster wider than the rest. */
@media (min-width: 900px) {
  .feed-strip button { min-width: 0; }
  .feed-strip .mk-strip {
    min-width: 0;
    font-size: clamp(9px, 1.3vh, 12px);
    letter-spacing: clamp(0.3px, 0.12vh, 1.4px);
  }
}
