/* ============================================================
   TokenGators — About page, content pass
   Palette and type from docs/tokengators-design-guide.md:
   #33ff33 primary, #28272a ground, #E0E0E0 body, WumpusMono, square corners.
   This pass is deliberately plain in its motion — the layout and the assets
   are the thing under review. Flow and transitions come in pass 2.
   ============================================================ */

@font-face {
  font-family: 'WumpusMono';
  src: url('../brand/WumpusMono.woff2') format('woff2');
  font-weight: 100 900;
  font-display: swap;
}

:root {
  --primary: #33ff33;
  /* The page's one ground. `--ground` was a second, lighter tone the body
     gradient ran to; it is gone, because the difference between the two was
     the border showing up wherever a plate ended. */
  --ground-deep: #1b1a1c;
  --body: #e0e0e0;
  --dim: rgba(224, 224, 224, 0.55);
  --line: rgba(51, 255, 51, 0.2);
  --mono: 'WumpusMono', ui-monospace, SFMono-Regular, Menlo, monospace;

  --gut: clamp(16px, 4vw, 48px);
  /* Top AND bottom of every section, so neighbours were 280px apart at the
     top of the range — 136px now. Comfortable is not the same as cavernous. */
  --sec-pad: clamp(36px, 4.5vh, 68px);
  /* Where a pinned header comes to rest. One value for all of them — the
     sticky offset of the ones that pin themselves, and the top padding of the
     stages that pin around their header — so every band locks to the same line
     and the prompt in the corner always sits ABOVE the top rule rather than
     between the two of them. */
  --head-top: clamp(64px, 9vh, 108px);
  /* The ground under every header band and every plate. Declared HERE, not on
     `.sec-title`: custom properties inherit downward, and a plate is the
     title's PARENT — so a plate asking for it got nothing and painted
     transparent, which is content reading straight through a pinned header. */
  --band-bg: #1d1c1f;
}

* { box-sizing: border-box; }

html { scroll-behavior: smooth; }

body {
  margin: 0;
  /* One continuous ground for the whole document. body's background propagates
     to the canvas, so it spans the full scroll height however long the page
     gets — no per-section colours to keep in sync, and no seams to line up.
     Sections differ by what is in them and by the header bands punctuating
     them, not by switching colour. */
  /* FLAT. This was a five-stop gradient between `--ground` (#28272a) and
     `--ground-deep` (#1b1a1c), which means the page really is lighter at two
     heights — and wherever a dark plate ended against one of those, the join
     read as a border. There was no element to find: it was the ground.

     At #1b1a1c the bands' #1d1c1f is two units away, so nothing shows a seam,
     and a band still reads as an object because of its rules and its type. */
  background: var(--ground-deep);
  color: var(--body);
  font-family: var(--mono);
  font-size: 16px;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
}

img, video { display: block; max-width: 100%; }
a { color: var(--primary); text-decoration: none; }

/* ------------------------------------------------------------ v1's topbar */
/* Nothing on THIS page uses these. v1.html does, and it shares this file, so
   they stay — see the v1 note in HANDOFF. v2's chrome is the block cursor
   below. */
.legacy-v1 #topbar {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: 40;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  height: 56px;
  padding: 0 var(--gut);
  background: rgba(40, 39, 42, 0.82);
  backdrop-filter: blur(8px);
  border-bottom: 1px solid var(--line);
}
.legacy-v1 .brand { display: flex; align-items: center; gap: 10px; font-weight: 700; }
.legacy-v1 .brand span { color: var(--primary); font-size: 15px; }
.legacy-v1 .topbar-label { color: var(--primary); opacity: 0.7; font-size: 12px; letter-spacing: 0.22em; }

/* Marks the top of the document for the observer that hides the bar. No
   positioned ancestor, so `top: 0` is the top of the page, and it adds nothing
   to the flow. */
.legacy-v1 #top-sentinel { position: absolute; top: 0; left: 0; width: 1px; height: 1px; }

/* Out of the way once the reader has left the top — but reachable, so it is
   hidden rather than gone. */
.legacy-v1 #topbar { transition: opacity 0.4s ease; }
body.legacy-v1.chrome-away #topbar { opacity: 0; }
body.legacy-v1.chrome-away #topbar > * { pointer-events: none; }
body.legacy-v1.chrome-away #topbar:hover,
body.legacy-v1.chrome-away #topbar:focus-within { opacity: 1; }
body.legacy-v1.chrome-away #topbar:hover > *,
body.legacy-v1.chrome-away #topbar:focus-within > * { pointer-events: auto; }
@media (prefers-reduced-motion: reduce) {
  .legacy-v1 #topbar { transition: none; }
}
@media (max-width: 640px) {
  .legacy-v1 #topbar { height: 48px; }
}

/* ------------------------------------------------------------------- nav */
/* A caret, top right. The whole navigation is behind it.

   It was a blinking block cursor, which was the right idea and the wrong
   object: a square of pure brand green pulsing in the corner of a cinematic
   opening pulls the eye off the film every second, forever. A caret says
   "there is more here" once and then holds still. */
/* Left, not right, because it is two things in sequence: the prompt the
   opening line types after, and then — once that line has gone — the menu. */
#nav {
  position: fixed;
  top: clamp(10px, 1.8vw, 20px);
  left: clamp(12px, 2vw, 26px);
  z-index: 70;
  font-family: var(--mono);
}
#nav-cursor {
  display: block;
  width: 30px;
  height: 30px;
  padding: 0;
  border: 0;
  background: none;
  color: var(--primary);
  cursor: pointer;
  opacity: 0.6;
  transition: opacity 0.25s;
}
/* Drawn, not typed. A `^` from the mono face gave whatever angle and weight
   that face happened to have; an SVG polyline makes both actual numbers — the
   viewBox's proportions set how wide the angle opens, the stroke sets the
   weight. */
#nav-cursor svg {
  display: block;
  width: 26px;
  height: 26px;
  /* Starts as `>`. The polyline is drawn pointing down, so a quarter turn
     anticlockwise is the prompt, and no turn at all is the menu. */
  transform: rotate(-90deg);
  transition: transform 0.35s ease;
}
/* Two questions, kept apart.

   WHAT IT SAYS: `caption-written` is one-way. Once the line has been read it
   is written, and the scroll-driven typing that used to un-write it on the way
   back up is off for good.

   WHETHER IT SHOWS: `caption-gone` follows the scroll. Past the television it
   hides; back at the top the line is simply there, already written and still
   blinking. Latching this one instead is what made the line vanish for the rest
   of the session the first time anyone scrolled down. Nothing re-animates in
   either direction, so there is nothing to flicker — only an opacity that
   fades. */
body.caption-written .run-caption {
  /* Only the blink survives the latch: the scroll-driven pair are what kept
     un-writing the line, and the cursor still has to look alive. */
  animation: cap-blink 1.06s steps(1, end) infinite;
  width: var(--cap-total, 22ch);
  opacity: 1;
}
/* Square, and hard-edged like a hardware cursor — the same cadence the quest
   prompt uses elsewhere on the page. It blinks the CARET only, by colouring
   the border, so the words it has already typed hold still. */
@keyframes cap-blink {
  0%, 55%   { border-right-color: var(--primary); }
  56%, 100% { border-right-color: transparent; }
}
body.caption-gone .run-caption,
body.caption-written.caption-gone .run-caption {
  animation: none;
  opacity: 0;
}
/* A fade rather than a cut, in both directions. The typing is a one-off; this
   is just the line being present or not. */
body.caption-written .run-caption { transition: opacity 0.4s ease; }

body.caption-gone #nav-cursor svg { transform: rotate(0deg); }
/* A line is printing after it, so it is a prompt again. Declared AFTER the rule
   above deliberately: the two have identical specificity, and this one has to
   win while it applies. */
body.typing #nav-cursor svg { transform: rotate(-90deg); }
/* Open turns it back into the prompt it started as, rather than flipping it
   up: `>` reads as "there is a list here", and pointing up would claim the
   list is above the button, which it is not. */
#nav-cursor[aria-expanded="true"] svg { transform: rotate(-90deg); }
#nav-cursor:hover,
#nav-cursor:focus-visible { opacity: 1; }
#nav-cursor[aria-expanded="true"] { opacity: 1; }
#nav-cursor:focus-visible { outline: 1px solid var(--line); outline-offset: 2px; }

/* Hangs from the caret, right-aligned with it. */
#nav-menu {
  position: absolute;
  top: 32px;
  left: 0;
  min-width: 200px;
  padding: 6px 0;
  background: rgba(4, 6, 4, 0.94);
  border: 1px solid var(--line);
}
#nav-menu a {
  display: block;
  padding: 7px 16px;
  text-align: left;
  color: var(--primary);
  text-decoration: none;
  font-size: 11px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  white-space: nowrap;
}
#nav-menu a:hover,
#nav-menu a:focus-visible { background: rgba(51, 255, 51, 0.14); outline: none; }
/* Not over the opening shot. The page begins on a full-bleed film and chrome
   on top of it gives the game away before the film has said anything. It
   arrives with the television, on the same latch that makes the keys live. */
/* Nothing is on the opening shot. The prompt arrives with the line it is a
   prompt for, which is the moment the television has landed. */
body:not(.intro-done) #nav { opacity: 0; pointer-events: none; }
#nav { transition: opacity 0.5s ease; }
@media (prefers-reduced-motion: reduce) {
  #nav { transition: none; }
  #nav-cursor svg { transition: none; }
}

/* ---------------------------------------------------------------- sections */

.sec {
  position: relative;
  padding: var(--sec-pad) var(--gut);
}

/* A pinned stage finishes exactly at the fold, so the section's bottom padding
   below it is a second gap stacked on the first. Pulled back, which leaves only
   the next section's own top padding between the two. */
.type-scroller { margin-bottom: calc(var(--sec-pad) * -1); }
@media (max-width: 900px), (prefers-reduced-motion: reduce) {
  /* Unpinned it is an ordinary block and wants the padding back. */
  .type-scroller { margin-bottom: 0; }
}

/* ---- headers that pin without a stage ----
 *
 * The foam hat and the team have no text to print, so there is nothing for a
 * typing stage to hold, so it is a plain sticky title over the section.
 *
 * ONLY THE TEAM. The foam hat's header was pinned here too, and it is the
 * cautionary case: a sticky box stays pinned for as long as its CONTAINING
 * BLOCK is in view, and `#hat` is four bands tall, so "pause for a couple of
 * scroll beats" came out as a header that rode the whole way down the page.
 * Pinning is only worth it where the containing block is about as tall as the
 * pause you want. The team's is the last section on the page and holds exactly
 * one window, which is the point of it. */
.sec > .plate,
.sec > .sec-title:not(.visually-hidden) {
  position: sticky;
  /* ZERO, the same as a typing stage. The room for the caret is the prompt
     strip's own `min-height: var(--head-top)` and the caret sits inside it —
     so offsetting the plate by `--head-top` as well pays for that room twice:
     the line lands a strip's height below the caret instead of beside it, the
     band sits that much too low, and the gap above the plate belongs to nobody,
     which is content scrolling through it. */
  top: 0;
  /* Over section content, under the prompt at 70 — that is page chrome and
     belongs on top of everything. */
  z-index: 45;
}
@supports (animation-timeline: view()) {
  /* The ONLY thing a pinned header needs that a travelling one does not: no
     `band-out`. It fades a header over its own pass across the screen, which
     for a pinned header is BEFORE it ever anchors — "the last header shows but
     immediately fades away" was exactly that. A pinned header leaves when the
     thing it is pinned to does.

     Everything else is the shared band arrival, on the header's OWN `view()`,
     over the range every other header uses. Pinning freezes that timeline, but
     only once the header has stopped — by which point the arrival finished
     (cover 44%, against a pin at roughly cover 82%) and the freeze simply holds
     the finished state. The timeline has to survive until the animation is
     done, not for ever.

     `::before`, `::after` and `.band-text` need no overrides at all for the
     same reason: the base rules already complete during the approach. */
  .plate .sec-title:not(.visually-hidden),
  .sec > .sec-title:not(.visually-hidden),
  .type-stage .sec-title:not(.visually-hidden) {
    animation: band-in linear both;
    animation-timeline: view();
    animation-range: cover 0% cover 44%;
  }
}
@media (max-width: 900px), (prefers-reduced-motion: reduce) {
  .sec > .plate,
  .sec > .sec-title:not(.visually-hidden) { position: static; }
}

/* (A per-section header hold lived here and has been removed. It pinned the
   title and printed it, but the headers on this page are a finished system
   already — full-bleed bands with rules that sweep out, and arrival and exit
   animations driven by `view()` on the title itself. Pinning freezes that
   view() (trap 2), and `band-out` has already faded a header out by the time
   it would pin, so the hold arrived invisible inside a shrunken band.
   Doing it properly means moving the band's own timeline to the section and
   retiming every header's arrival and exit — a change to that system, not an
   addition to it.) */

.sec-title {
  margin: 0 0 8px;
  font-size: clamp(30px, 6vw, 76px);
  line-height: 1.02;
  font-weight: 700;
  letter-spacing: -0.01em;
  text-transform: uppercase;
  color: var(--primary);
  text-align: center;
}
.sec-kicker {
  margin: 0 0 clamp(28px, 5vh, 56px);
  text-align: center;
  color: var(--dim);
  font-size: 12px;
  letter-spacing: 0.2em;
  text-transform: uppercase;
}

.sec-body { max-width: 660px; margin: clamp(40px, 7vh, 80px) auto 0; text-align: center; }

/* Right-aligned wherever they appear. Centred, a single short link under a
   full-width block left a lot of empty floor either side of it; on the right
   edge it reads as a way onward rather than as a caption. No horizontal
   padding of its own — .sec already sets the gutter, so the row's box ends
   exactly there. */
.cta-row { margin-top: clamp(6px, 1vh, 12px); text-align: right; }
/* The office section strips its own gutters and has to put them back. */
.sec-office .cta-row { padding: 0 var(--gut); }

/* A link, not a button. The bordered pill read as a form control parked at the
   bottom of the section — a box you press rather than a way on. Sentence case
   as authored, sized to sit with the copy, and the hairline under it is the
   part that reacts, so the label stays still while the link comes alive. */
.cta {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 0.55em;
  margin-top: 0;
  padding: 4px 2px 7px;
  border: 0;
  color: var(--primary);
  /* Small. It is a way onward, not a headline — at 19px it was competing with
     the copy it sits under. */
  font-size: clamp(12px, 1.1vw, 15px);
  letter-spacing: 0.04em;
  text-transform: none;
}
.cta::before {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 1px;
  background: currentColor;
  opacity: 0.32;
  transform-origin: 0 50%;
  transition: opacity 0.3s, transform 0.45s cubic-bezier(0.2, 0.7, 0.2, 1);
}
/* The arrow is the affordance; it leans towards where it is taking you. */
.cta::after {
  content: '\2192';
  transition: transform 0.45s cubic-bezier(0.2, 0.7, 0.2, 1);
}
.cta:hover::before,
.cta:focus-visible::before { opacity: 0.85; }
.cta:hover::after,
.cta:focus-visible::after { transform: translateX(7px); }

/* The office banner is the link. Nothing moves on hover — it is a wide
   illustration and a scale would fight the parallax running inside it — so the
   lift is in brightness alone. */
.banner-link { display: block; line-height: 0; }
.banner-link img { transition: filter 0.4s; }
.banner-link:hover img,
.banner-link:focus-visible img { filter: brightness(1.08); }

/* ------------------------------------------------------- 01 the opening shot
   There is no hero section any more. The page opens on the run's pinned stage
   with the television scaled up so its tube fills the window, so the film is
   full-bleed exactly as before — see the pull-back in the dark-run block. */

/* The mute, on the picture.
   It lives inside the tube, so it travels and shrinks with it, and it is gone
   by the time the pull-back lands — after which the set has a real MUTE key
   and a second floating one would just be clutter.

   `pointer-events: auto` is not optional: `.run-layer.is-tv` is
   pointer-events: none until the set latches on, which is most of the way
   through the pull-back — exactly the stretch where this is the only control
   there is.

   Why a control at all, rather than simply starting with sound on: browsers do
   not autoplay audio without a gesture. A page that "starts with sound on"
   either does not start or starts silent and lies about it. */
#sound-toggle {
  /* FIXED, on the document. It was absolute inside the run's stage — which is
     one window tall and scrolls away with the run, so the control left at the
     same moment the rest of the page started playing sound with no way to stop
     it. Top right, clear of the prompt in the other corner and above the review
     buttons at 68px. Under the prompt's 70: that is navigation. */
  position: fixed;
  z-index: 60;
  pointer-events: auto;
  top: clamp(14px, 1.8vw, 22px);
  right: clamp(12px, 2vw, 26px);
  /* No pill. A bordered capsule on the picture reads as a form control parked
     on a film; the icon alone reads as part of the set. */
  display: block;
  padding: 6px;
  border: 0;
  background: none;
  cursor: pointer;
  /* Always the page's green. The slash says "muted" without depending on
     colour, and the set's own MUTE lamp says it in red — one red light is
     enough, and it belongs on the thing that looks like a lamp. */
  color: var(--primary);
  /* Dim is the resting state: the page is silent more of the time than not. */
  opacity: 0.28;
  transition: opacity 0.45s ease, color 0.3s;
  /* It sits on whatever the film is showing, which may be anything. */
  filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.9));
}
/* THREE STATES, and the one that sets the brightness is whether the page is
   actually making a noise — not where you have scrolled to:

     muted                       slash, dim
     unmuted, something playing  waves, full
     unmuted, nothing playing    waves, dim

   So it lights up when you turn the sound on at the top, steps back past the
   television where nothing is running, and lights up again at WG8R the moment
   something plays — without you having touched it, because you never turned it
   off. */
body.audible #sound-toggle { opacity: 1; }
/* Scoped past that: `body.audible #sound-toggle` is (1,1,1) and a bare
   `#sound-toggle:hover` is (1,1,0), so the unscoped version would lose. */
#sound-toggle:hover,
#sound-toggle:focus-visible,
body.audible #sound-toggle:hover,
body.audible #sound-toggle:focus-visible { opacity: 1; }
#sound-toggle svg {
  display: block;
  width: clamp(30px, 3.2vw, 42px);
  height: auto;
}
/* The cone is solid, the waves and the slash are drawn — so the state reads as
   a SHAPE as well as a colour. Telling amber from green is not something to
   rely on by itself. */
#sound-toggle .spk-cone { fill: currentColor; }
#sound-toggle .spk-wave,
#sound-toggle .spk-slash {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
}
#sound-toggle .spk-wave { display: none; }
#sound-toggle[aria-pressed="true"] .spk-wave { display: inline; }
#sound-toggle[aria-pressed="true"] .spk-slash { display: none; }

/* (A scroll-driven fade lived here, on `--run`. It cannot: a named
   view-timeline is visible only to DESCENDANTS of the element that declares
   it, and `--run` is on the run's scroller — so once this button moved out to
   the document the animation would have had nothing to run against and would
   have quietly done nothing at all. The dim is `body.mute-dim`, set by the
   scroll handler, which already has the geometry and cannot fail in silence.) */


/* ---- scroll cue ----
   Drawn in CSS rather than served as an image: the coin GIF that used to sit
   here carries its own black matte and read as a tile over the film. */
.scroll-cue {
  position: absolute;
  z-index: 3;
  left: 50%;
  bottom: calc(var(--gut) + 2px);
  transform: translateX(-50%);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 9px;
  color: var(--primary);
  font-size: 10px;
  letter-spacing: 0.3em;
  /* Letter-spacing adds a trailing gap after the last letter, which throws a
     centred word to the left by half of it. This puts it back. */
  text-indent: 0.3em;
  opacity: 0.85;
  transition: opacity 0.5s ease;
}
.scroll-cue:hover { opacity: 1; }
/* A chevron from two borders — no glyph, no asset. */
.scroll-cue-arrow {
  width: 11px;
  height: 11px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  /* Also set here, not only in the keyframes: with the animation suppressed
     this is what keeps it a chevron instead of a square corner. */
  transform: rotate(45deg);
  animation: cue-bob 2.1s ease-in-out infinite;
}
@keyframes cue-bob {
  0%, 100% { transform: rotate(45deg) translate(0, 0); opacity: 0.5; }
  50%      { transform: rotate(45deg) translate(4px, 4px); opacity: 1; }
}
/* Stop nagging the moment they've started moving. */
body.scrolled .scroll-cue { opacity: 0; pointer-events: none; }

/* ---------------------------------------------------------------- 02 collection */

/* The montage is not a layer any more — it is channel 1, and the tube sizes
   it. Its old sizing rule and its own sound toggle lived here; the toggle is
   gone because the set has MUTE and VOL of its own, which is one control for
   one sound rather than two that had to agree. */

/* ---- gator carousel ---- */

/* Was one fixed gator with a hover zoom; now a set you can page through. */
.carousel {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: clamp(8px, 2vw, 24px);
  margin: clamp(40px, 8vh, 90px) auto 0;
  max-width: 96vw;
}
.carousel-reel {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: clamp(6px, 1.6vw, 20px);
}
.carousel-stage {
  position: relative;
  /* Back up to full size: the film that it used to sit directly beneath is now
     two sections away, so there is nothing left for it to compete with. */
  flex: 0 0 auto;
  width: min(620px, 50vw);
  aspect-ratio: 1;
  background: #000;
  border: 1px solid var(--line);
  overflow: hidden;
}
/* Out-of-focus neighbours. Smaller, dimmer and blurred so the eye stays on the
   centre gator but the section still reads as a big collection. */
.carousel-side {
  flex: 0 0 auto;
  width: min(250px, 20vw);
  aspect-ratio: 1;
  overflow: hidden;
  filter: blur(7px) brightness(0.5);
  opacity: 0.85;
  pointer-events: none;
}
.carousel-side img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  /* Scaled up a touch so the blur doesn't feather in the frame edges. */
  transform: scale(1.08);
}
/* Below tablet the flankers would squeeze the centre to nothing. */
@media (max-width: 700px) {
  .carousel-side { display: none; }
  .carousel-stage { width: min(620px, 74vw); }
}
.carousel-stage img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  /* Cross-fade between gators rather than a hard swap. */
  animation: carousel-in 0.42s ease;
}
@keyframes carousel-in { from { opacity: 0; } to { opacity: 1; } }
/* Under the frame rather than over it, same green and same size as the old
   overlay. The trait list is gone — it was covering the art it described. */
.carousel-caption {
  margin: 14px 0 0;
  text-align: center;
  color: var(--primary);
  font-size: 11px;
  letter-spacing: 0.18em;
}
/* The same chevron the prompt in the corner is, turned a quarter each way —
   same viewBox, same 2.6 stroke. No box: a bordered square around a glyph from
   whatever font the system supplied is two things nothing else here does. */
.carousel-nav {
  flex: 0 0 auto;
  width: clamp(30px, 3.4vw, 40px);
  height: clamp(30px, 3.4vw, 40px);
  display: grid;
  place-items: center;
  background: none;
  border: 0;
  padding: 0;
  color: var(--primary);
  line-height: 1;
  cursor: pointer;
  transition: background 0.25s, border-color 0.25s, transform 0.25s;
}
.carousel-nav svg {
  display: block;
  width: 26px;
  height: 26px;
}
/* Drawn pointing down, so a quarter turn each way gives the pair. */
.carousel-nav.prev svg { transform: rotate(90deg); }
.carousel-nav.next svg { transform: rotate(-90deg); }
/* A glow, not a box. The border was the hover state of a button that had one
   to begin with; the chevron is the whole control now. */
.carousel-nav:hover,
.carousel-nav:focus-visible {
  filter: drop-shadow(0 0 6px rgba(51, 255, 51, 0.85));
  transform: scale(1.12);
}
/* ---------------------------------------------------------------- 03 mint */

.bleed {
  margin: clamp(44px, 8vh, 88px) calc(var(--gut) * -1) 0;
  display: grid;
  gap: 4px;
}
.bleed img { width: 100%; }

/* ------------------------------------------------------- 04 the foam hat */

.sec-hat { padding-left: 0; padding-right: 0; }.sec-hat .sec-title,
.sec-hat .sec-kicker { padding: 0 var(--gut); }
/* The title carries its own line break, set in the manifest. */
/* Natural case to match "a SuperPaperThings Production" at the foot of the
   page: these read as names, not signage. Tracking goes back to 0 with it —
   letter-spacing exists to open up all-caps and only loosens mixed case. */
#hat-headline {
  white-space: pre-line;
  text-transform: none;
  letter-spacing: 0;
}

/* Header: the title alone. The gator that sat above it is gone — it read as a
   stray object on top of the section rather than part of the header. */
/* A BOUNDED hold. The header is sticky, but inside a wrapper that is exactly
   itself plus `--hat-hold` — so the pin lasts the hold rather than the whole
   four-band section, which is what made it feel like the header was following
   you down the page.

   The negative margin is the other half: without it the hold would be empty
   space between the header and the first band. With it, the bands start
   directly under the header and travel up underneath it while it is held. */
/* The header AND the bands, pinned as one. Pinning the header on its own let
   the bands slide under it while the line was still printing; in one stage
   nothing moves until the hold is spent and then the whole thing leaves
   together — which is what a typing stage does.

   The hold is generated content, NOT padding: a sticky box is confined to its
   containing block, and a block child's containing block is the parent's
   CONTENT box. Padding sits outside it, which is why turning this number up
   twice bought no hold at all. */
.hat-scroller { --hat-hold: 80svh; }
.hat-scroller::after {
  content: '';
  display: block;
  height: var(--hat-hold);
}
.hat-stage {
  position: sticky;
  /* Zero: the room for the caret is the prompt strip inside the plate. */
  top: 0;
  z-index: 45;
}
.hat-head { text-align: center; }
@media (max-width: 900px), (prefers-reduced-motion: reduce) {
  .hat-scroller::after { display: none; }
  .hat-stage { position: static; }
}
.hat-head .sec-title { position: relative; z-index: 2; }

/* ---- bands ---------------------------------------------------------------
   Each band is a full-bleed slab, roughly twice the height of the scrolling
   strip inside it. Layers, back to front:

     .band-mosaic  the band's own images, tiled dense and SHARP — this is what
                   carries the sense of volume, so it is deliberately not
                   blurred; it's only dimmed
     .band-scrim   a horizontal wash concentrated on the middle third, so the
                   strip reads without hiding the mosaic above and below it
     .band-track   the scrolling row, vertically centred in the slab
     .band-label   the title on a solid plate
*/

.hat-band {
  position: relative;
  overflow: hidden;
  /* ~2x the strip tile height, so a good band of mosaic shows above and below */
  height: clamp(300px, 44vh, 440px);
  margin-bottom: clamp(20px, 3.4vh, 40px);
  background: #0a0a0b;
  isolation: isolate;
}

/* Dense sharp tiling. auto-fill with a small min means the browser packs in as
   many columns as the viewport allows — wide screens get more tiles rather
   than bigger ones, which is the "looks like a lot" effect. */
.band-mosaic {
  position: absolute;
  inset: 0;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(clamp(54px, 6vw, 88px), 1fr));
  grid-auto-rows: clamp(54px, 6vw, 88px);
  gap: 2px;
  overflow: hidden;
  pointer-events: none;
  /* No blur. Dimmed just enough that the strip and title sit on top clearly. */
  filter: brightness(0.5) saturate(0.9);
}
.band-mosaic img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* Contrast where it's needed — a horizontal band through the middle — leaving
   the top and bottom of the mosaic legible. */
.band-scrim {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background:
    linear-gradient(to bottom,
      rgba(10, 10, 11, 0.15) 0%,
      rgba(10, 10, 11, 0.60) 24%,
      rgba(10, 10, 11, 0.78) 50%,
      rgba(10, 10, 11, 0.60) 76%,
      rgba(10, 10, 11, 0.15) 100%),
    linear-gradient(to right,
      var(--ground-deep) 0%, transparent 14%, transparent 86%,
      var(--ground-deep) 100%);
}

/* The scrolling row: two identical halves, animated 0 -> -50% so the seam
   lands exactly where the copy starts and the loop is invisible. translate3d
   keeps it on the GPU and `linear` makes it a constant glide.

   No hover pause — stopping under the cursor reads as a stutter, especially
   with several bands moving at once. */
.band-viewport {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;          /* centres the strip in the taller slab */
  overflow: hidden;
  -webkit-mask-image: linear-gradient(to right, transparent, #000 7%, #000 93%, transparent);
          mask-image: linear-gradient(to right, transparent, #000 7%, #000 93%, transparent);
}
.band-track {
  display: flex;
  gap: 14px;
  width: max-content;
  will-change: transform;
  animation: band-slide var(--band-duration, 70s) linear infinite;
}
.band-track.reverse { animation-direction: reverse; }
@keyframes band-slide {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(-50%, 0, 0); }
}

.band-track figure {
  margin: 0;
  flex: 0 0 auto;
  width: clamp(130px, 15vw, 200px);
  aspect-ratio: 1;
  overflow: hidden;
  background: #0c0c0d;
  border: 1px solid rgba(255, 255, 255, 0.12);
  border-radius: 2px;
  /* Lifts the strip off the sharp mosaic behind it. */
  box-shadow: 0 6px 22px rgba(0, 0, 0, 0.55);
}
.band-track img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* Title plate. Over an unblurred mosaic a label needs a real ground, not a
   text-shadow. */
.band-label {
  position: absolute;
  z-index: 3;
  left: 0;
  top: 0;
  padding: 12px var(--gut);
  background: linear-gradient(to bottom right,
    rgba(12, 12, 13, 0.94) 0%, rgba(12, 12, 13, 0.82) 100%);
  border-right: 1px solid rgba(51, 255, 51, 0.18);
  border-bottom: 1px solid rgba(51, 255, 51, 0.18);
  /* Wide enough for the longest band title on one line. At the old 520px cap
     "…FOAM HATS BY PAPERD" needed ~600px and broke over two lines. */
  max-width: min(94%, 780px);
  pointer-events: none;
}
.band-label h3 {
  margin: 0;
  color: var(--primary);
  font-size: clamp(14px, 1.9vw, 24px);
  line-height: 1.12;
  font-weight: 700;
  /* Natural case, matching "a SuperPaperThings Production" at the foot of the
     page. Tracking goes with it: letter-spacing exists to open up all-caps and
     only loosens mixed case. */
  text-transform: none;
  letter-spacing: 0;
  white-space: nowrap;
}
/* .band-count is no longer rendered. */
/* Reversed bands anchor the plate to the other corner, so the eye zig-zags
   down the section instead of tracking one edge. */
.hat-band.reverse .band-label {
  left: auto;
  right: 0;
  text-align: right;
  border-right: none;
  border-left: 1px solid rgba(51, 255, 51, 0.18);
  background: linear-gradient(to bottom left,
    rgba(12, 12, 13, 0.94) 0%, rgba(12, 12, 13, 0.82) 100%);
}

/* PHONE ONLY — no `, (prefers-reduced-motion: reduce)` on this one. Most of
   the narrow-screen blocks in this file carry that comma, which makes them
   OR'd rather than phone-only: a desktop reader with reduced motion on gets
   them too. Nothing here should reach a desktop. */
@media (max-width: 900px) {
  /* The mosaic behind each band is meant to read as texture. Its cells are
     `clamp(54px, 6vw, 88px)`, and on a 390px screen the 6vw term is 23px so
     the FLOOR wins — seven columns across, where a desktop gets seventeen, and
     seven columns is blocks rather than texture. The floor is what is wrong,
     not the idea, so the phone gets its own term at roughly the same density. */
  .band-mosaic {
    grid-template-columns:
      repeat(auto-fill, minmax(clamp(28px, 8.5vw, 54px), 1fr));
    grid-auto-rows: clamp(28px, 8.5vw, 54px);
  }
}

@media (max-width: 720px) {
  .hat-band { height: clamp(240px, 40vh, 320px); }
  .band-label { max-width: 100%; width: 100%; text-align: left; }
  .hat-band.reverse .band-label { text-align: left; }
}

@media (prefers-reduced-motion: reduce) {
  .band-track { animation: none; }
  .band-viewport { overflow-x: auto; }
}

/* ---------------------------------------------------------------- 05 memes */

/* Should look like a folder, not a gallery: uneven, loose, unframed.
   column-count takes an integer, so this steps rather than fluidly clamping. */
.masonry {
  /* Denser than before: more, smaller tiles so the section reads as a folder
     rather than a wall of large images. */
  columns: 3;
  column-gap: 6px;
  margin-top: clamp(24px, 4vh, 44px);
}
@media (min-width: 700px)  { .masonry { columns: 4; } }
@media (min-width: 1000px) { .masonry { columns: 5; } }
@media (min-width: 1400px) { .masonry { columns: 6; } }
@media (min-width: 1800px) { .masonry { columns: 7; } }
.masonry figure {
  margin: 0 0 6px;
  break-inside: avoid;
  position: relative;
  background: #000;
}
.masonry img { width: 100%; display: block; }

/* ------------------------------------------------------------- 06 gtv */

/* ---------------------------------------------------------------- the TV */

/* One set, no menu. Channel up/down cuts to a random other film; volume
   up/down steps the level. The cabinet is drawn in CSS rather than an image
   so it scales cleanly and needs no asset. */
.tv {
  /* The tube is only 54% of the cabinet's width, so the set has to run wider
     than a bare video would to leave the picture a watchable size. */
  max-width: min(1120px, 94vw);
  margin: clamp(28px, 5vh, 56px) auto 0;
  /* The tube's own centre, so scaling keeps the PICTURE still while the set
     grows and shrinks around it.

     Measured, not written down. The tube's centre is 47.5% of the CABINET's
     height, but this origin resolves against `.tv` — and when `.tv` also
     carried the CTA row it was taller than the cabinet, so 47.5% landed below
     the real centre and levered the set upward as it grew: black along the
     bottom of the opening shot and none at the top.

     `.tv` is exactly the cabinet again now, so the measured value comes out
     at 47.5% anyway. It stays measured because the bug was not the number, it
     was assuming the number without checking what it resolved against. */
  transform-origin: 50% var(--tv-origin-y, 47.5%);
}
/* ---- the photographed cabinet ----
   GEOMETRY. Every live part is placed as a percentage of the frame photograph,
   so re-registering the set to a different photo means editing only these
   numbers. All of them are measured, not estimated: run `calibrate-tv.py`,
   which reads the aperture and the key seams off the pixels. */
.tv-cabinet {
  position: relative;
  /* The photograph is 1448x1086, and this says so.

     The height used to come from the image itself — elegant, in that nothing
     in the CSS had to know the aspect ratio, but it meant the cabinet was
     ZERO TALL until the file decoded. Everything measured off it was therefore
     unmeasurable at first paint: `--pull` fell back to a guess and then SNAPPED
     to the real value when the image landed, which is the jump in the middle
     of the pull-back. Stating the ratio makes the layout correct from the
     first frame and removes the shift as well. */
  aspect-ratio: 1448 / 1086;
  /* So the LED digits can be sized against the cabinet instead of the viewport
     — the set is width-capped, so vw units drift out of proportion with it. */
  container-type: inline-size;

  /* the tube — a real hole in the PNG, 780x516 px of the 1448x1086 photo */
  --screen-x: 13.81%;
  --screen-y: 23.76%;
  --screen-w: 53.87%;
  --screen-h: 47.51%;
  /* Overfill, so no seam can show along the aperture's curved edge. The
     cabinet is painted over the top of this, and hides the excess. */
  --screen-bleed: 1.4%;

  /* the display glass above the button plate — the olive panel itself, not the
     dark recess it is set into. Measuring the recess put the digits up and to
     the left of the glass, which is what you notice. Read off the magnified
     crop: `calibrate-tv.py` deliberately declines to guess this one, because
     the glass and the vent bars beside it are the same darkness. */
  --led-x: 79.07%;
  --led-y: 62.71%;
  --led-w: 5.80%;
  --led-h: 4.79%;

  /* the 2x3 key plate: CH+ VOL+ / CH- VOL- / PWR MUTE, after relabel-tv-keys.py
     regrouped the printed labels. The left column is genuinely wider than the
     right one on this set. */
  --btn-col1: 77.69%;
  --btn-col2: 87.15%;
  --btn-w1: 9.19%;
  --btn-w2: 8.01%;
  --btn-h: 3.22%;
  --btn-row1: 69.24%;
  --btn-row2: 72.93%;
  --btn-row3: 76.43%;
}
/* In flow, so the photo's own proportions set the cabinet's height and nothing
   here has to know its aspect ratio. Sits over the tube: the aperture is
   transparent, so the picture shows through the cabinet's own bezel and keeps
   its curved corners exactly. */
.tv-frame {
  position: relative;
  display: block;
  width: 100%;
  height: auto;
  z-index: 2;
  pointer-events: none;
  filter: drop-shadow(0 26px 50px rgba(0, 0, 0, 0.6));
}

/* Behind the cabinet, overfilling the hole it shows through. */
.tv-screen {
  position: absolute;
  left: calc(var(--screen-x) - var(--screen-bleed));
  top: calc(var(--screen-y) - var(--screen-bleed));
  width: calc(var(--screen-w) + var(--screen-bleed) * 2);
  height: calc(var(--screen-h) + var(--screen-bleed) * 2);
  background: #05060a;
  overflow: hidden;
  z-index: 1;
}
/* Powered off: the tube goes dark but the set stays put. */
.tv-cabinet.off .tv-screen video,
.tv-cabinet.off .tv-glare { opacity: 0; }
.tv-cabinet.off .tv-screen { background: #05060a; }
.tv-screen video {
  width: 100%;
  height: 100%;
  /* A default only: renderGtv overrides this per channel from the file's real
     dimensions. Cover is right for widescreen; a square needs backing off,
     because cover on 1:1 in a 3:2 tube throws away a THIRD of the height and
     the coin at the end of the montage lost its top and bottom to it. */
  object-fit: cover;
  display: block;
  transform-origin: 50% 50%;
}
/* (A `.is-boxy` rule lived here that fitted square content and pushed it back
   in. Nothing ever added the class, so it matched nothing — trap 5. The
   montage's treatment is set from js instead, off the file's real shape.) */

/* Snow. Over the programme, under the scanlines and the glare, so it reads as
   something the TUBE is doing rather than something laid on the page.
   Fades rather than cuts, but only just — long enough not to click, short
   enough that it never feels like a transition in its own right. */
.tv-snow {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  opacity: 0;
  pointer-events: none;
  /* Drawn small and blown up hard. Smoothing a field of random pixels turns
     snow into grey soup; leaving it blocky is what makes it read as analogue
     rather than as a dirty JPEG. */
  image-rendering: pixelated;
}
/* No fade in. A set cutting to snow does it instantly — easing it looks like a
   dissolve, which is the one thing this must not resemble. Easing OUT is fine
   and hides the joint where the picture takes over. */
/* No fade in, and no CSS motion: the canvas draws the roll and the tearing
   itself, frame by frame, because it is displacing bands of a real picture
   rather than moving one rectangle around. A transform on top of that would
   just slide the whole effect. */
.tv-screen.tuning .tv-snow { opacity: 1; }
.tv-screen .tv-snow { transition: opacity 90ms linear; }

/* Scanlines and a corner glare sell the CRT without touching the video. */
.tv-scan {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: repeating-linear-gradient(
    to bottom, rgba(0,0,0,0.16) 0 1px, transparent 1px 3px);
  mix-blend-mode: multiply;
}
.tv-glare {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background:
    radial-gradient(120% 80% at 22% 6%, rgba(255,255,255,0.10), transparent 55%),
    radial-gradient(100% 100% at 50% 50%, transparent 55%, rgba(0,0,0,0.55) 100%);
}

/* ---- the channel bug ----
   The station ident, sitting permanently in the bottom-right of the picture.
   Inset well clear of the aperture's curved corner, which the bezel overlaps —
   at the corner itself it would be half-hidden by the cabinet. Sized off the
   cabinet (same container as the LED) so it stays in step with the channel
   digits at any width. */
.tv-bug {
  position: absolute;
  right: 8%;
  bottom: 9%;
  height: 3.2cqw;
  opacity: 0.4;               /* 60% transparent */
  pointer-events: none;
  transition: opacity 0.3s;
}
.tv-bug img {
  height: 100%;
  width: auto;
  display: block;
  /* Keeps the mark legible over a bright frame without reading as a box. */
  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.7));
}
/* A dark set isn't broadcasting anything to identify. */
.tv-cabinet.off .tv-bug { opacity: 0; }

/* On-screen display for channel / volume, the way a real set flashes them. */
/* Inset from the screen rect's own edges, which the bezel overlaps — at a fixed
   pixel offset the readout would sit under the cabinet. */
.tv-osd {
  position: absolute;
  top: 9%;
  left: 7%;
  padding: 6px 10px;
  background: rgba(0, 0, 0, 0.6);
  color: var(--primary);
  font-size: 11px;
  letter-spacing: 0.2em;
  opacity: 0;
  transition: opacity 0.25s;
  pointer-events: none;
}
.tv-osd.show { opacity: 1; }

/* ---- the channel LED ----
   The little window above the button plate. Segment-style digits on the dark
   glass, with the faint bloom a real LED throws onto it. */
.tv-led {
  position: absolute;
  left: var(--led-x);
  top: var(--led-y);
  width: var(--led-w);
  height: var(--led-h);
  z-index: 3;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--mono);
  /* vw first as a floor for browsers without container queries, then the real
     value: the glass is ~5% of the cabinet's height, so digits at 2.8cqw fill
     it at any width the set is drawn at. */
  font-size: clamp(9px, 1.5vw, 21px);
  font-size: 2.8cqw;
  font-weight: 700;
  letter-spacing: 0.06em;
  color: #ff5f3a;
  text-shadow: 0 0 5px rgba(255, 95, 58, 0.85), 0 0 14px rgba(255, 60, 20, 0.5);
  pointer-events: none;
  transition: opacity 0.12s;
}
/* One frame of dropout as the tuner moves, the way a real segment display
   blanks between values. */
/* !important because the arrival sequence animates opacity on this element,
   and an animation's fill outranks a normal declaration — without it the
   segment dropout on channel change would be permanently overridden by the
   reveal's resting opacity of 1, and the display would stop blinking. */
.tv-led.blink { opacity: 0.15 !important; }
.tv-cabinet.off .tv-led { opacity: 0; }

/* ---- the buttons ----
   Transparent hit areas laid exactly over the six buttons in the photograph.
   What you press is the button in the image; these only catch the click. */
.tv-hit {
  position: absolute;
  width: var(--btn-w1);
  height: var(--btn-h);
  z-index: 4;
  padding: 0;
  background: transparent;
  border: 0;
  border-radius: 3px;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: background 0.12s, box-shadow 0.12s;
}
/* The drawn buttons are small. This widens what counts as a press without
   widening what lights up when you do. */
.tv-hit::before {
  content: '';
  position: absolute;
  inset: -6px -4px;
}
/* A press reads as the button darkening in its well, not as a new UI element
   appearing on top of the photo. */
.tv-hit:hover { background: rgba(255, 255, 255, 0.16); }
.tv-hit:focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; }

/* Left column: channel, then power. Right column: volume, then mute. Each rule
   has to match the label printed on that key in the photo. */
.hit-ch-up  { left: var(--btn-col1); top: var(--btn-row1); }
.hit-ch-dn  { left: var(--btn-col1); top: var(--btn-row2); }
.hit-pwr    { left: var(--btn-col1); top: var(--btn-row3); }
.hit-vol-up { left: var(--btn-col2); top: var(--btn-row1); width: var(--btn-w2); }
.hit-vol-dn { left: var(--btn-col2); top: var(--btn-row2); width: var(--btn-w2); }
.hit-mute   { left: var(--btn-col2); top: var(--btn-row3); width: var(--btn-w2); }

/* The MUTE key's lamp. The little square before the word is part of the
   PHOTOGRAPH, so it cannot be recoloured — this is a light laid over it, the
   same way every other live part of this cabinet is laid over the picture of
   one. Dark until the sound is off, then red. */
.hit-mute::after {
  content: '';
  position: absolute;
  left: 6.5%;
  top: 36%;
  width: 7%;
  height: 26%;
  border-radius: 1px;
  background: transparent;
  transition: background 0.25s ease, box-shadow 0.25s ease;
}
body.muted .hit-mute::after {
  background: #ff3b30;
  box-shadow: 0 0 6px rgba(255, 59, 48, 0.9);
}
/* A dark set has no lamps lit. */
.tv-cabinet.off .hit-mute::after { background: transparent; box-shadow: none; }

/* Fallback when the cabinet photo is missing. Same absolute layout, different
   geometry: a drawn plate with the tube across the top and the six controls
   laid out below it, labelled, so nothing becomes a dead pixel. */
.tv-cabinet.no-frame {
  aspect-ratio: 4 / 3;
  background: linear-gradient(160deg, #4a3b2e, #2e241b 45%, #3d3025);
  border-radius: 14px;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.09),
              0 24px 60px rgba(0, 0, 0, 0.55);

  --screen-x: 4%;   --screen-y: 4%;   --screen-w: 92%;  --screen-h: 70%;
  --screen-bleed: 0%;   /* no cabinet to hide an overfill behind */
  --led-x: 5%;      --led-y: 79%;     --led-w: 16%;     --led-h: 7%;
  --btn-col1: 40%;  --btn-col2: 68%;  --btn-h: 5.5%;
  --btn-w1: 26%;    --btn-w2: 26%;
  --btn-row1: 78%;  --btn-row2: 85.5%; --btn-row3: 93%;
}
.tv-cabinet.no-frame .tv-frame { display: none; }
.tv-cabinet.no-frame .tv-screen { border-radius: 22px / 34px; }
.tv-cabinet.no-frame .tv-led {
  border: 1px solid rgba(0, 0, 0, 0.6);
  background: rgba(0, 0, 0, 0.5);
  font-size: clamp(10px, 1.8vw, 20px);
}
.tv-cabinet.no-frame .tv-hit {
  display: flex;
  align-items: center;
  justify-content: center;
  background: linear-gradient(180deg, #59493a, #3a2e24);
  border: 1px solid rgba(0, 0, 0, 0.55);
  color: #e6d9bd;
  font-family: var(--mono);
  font-size: clamp(7px, 0.9vw, 10px);
  letter-spacing: 0.1em;
  text-transform: uppercase;
}
.tv-cabinet.no-frame .tv-hit::after { content: attr(aria-label); }

/* ------------------------------------------------------------ the office */

.sec-office { padding-left: 0; padding-right: 0; }
.sec-office .sec-title,
.sec-office .sec-body { padding-left: var(--gut); padding-right: var(--gut); }

/* One full-bleed banner. No frame — it runs the width of the page like the
   mint image does. */
.office-banner {
  margin: clamp(28px, 5vh, 56px) 0 0;
  background: #000;
  line-height: 0;
  /* The parallax inside scales the image past the frame on purpose; without
     this it spills over the section instead of travelling behind a window.

     And because clipping makes this a scroll container, the parallax below can
     NOT use view() on the image: it would measure the image against this box,
     which never scrolls, and the animation would silently never run — as it
     never did. The timeline is named here and read by the image instead. */
  overflow: hidden;
  view-timeline-name: --office;
  view-timeline-axis: block;
}
.office-banner img { width: 100%; height: auto; }

/* -------------------------------------------------------- the gator team */

/* The team title is a sentence, not a label: the shared .sec-title uppercases
   everything, which would render the leading "a" as "A". Transform off, and
   sized in vw with nowrap so all 29 characters stay on one line — at ~0.6em
   per character in this monospace that needs roughly 4.6vw. */
/* The header pins at the top of the window now, so it no longer needs pushing
   clear of the fixed prompt — the prompt simply sits in its corner over the
   band. The padding is back to every other section's, which is also part of
   what buys the room for the grid and the footer to be in frame while the
   header is still held: this is the last header on the page and the ask is
   that you reach the bottom without scrolling it away. */

/* Natural case and no tracking, because it is a name rather than signage —
   but the SIZE is the shared one. It was `min(4.6vw, 42px)` against the band's
   `clamp(30px, 6vw, 76px)`, which is about half, and smaller type also made a
   shorter band: the last header on the page read as a different object from
   every other one. `nowrap` went with the size — at the shared size it wraps
   where it has to, the way the foam hat's two-line title does. */
#team-headline {
  text-transform: none;
  letter-spacing: 0;
  /* One line. "a SuperPaperThings Production" is 29 characters and this face
     averages about 0.56em each, so it needs roughly 16.2 times the font size in
     width; dividing the available width by 17 leaves a little margin for the
     scrollbar `100vw` includes and for the estimate being an estimate. The
     shared clamp still caps it, so on a wide window it is the same size as
     every other header and only comes down where it has to. */
  font-size: min(clamp(30px, 6vw, 76px), calc((100vw - 2 * var(--gut)) / 17));
  white-space: nowrap;
}

/* One window, shared. Arriving at the bottom of the page leaves nothing of the
   section before it on screen, the header has the room it needs to reach its
   resting line, and the run below it — grid, then footer — is exactly a window,
   so the grid lands under the header rather than behind it.

   The footer is in here for that last reason: the sum that has to fit includes
   it. With it outside, "tall enough for the header to pin" and "short enough to
   keep the grid in frame" cannot both be satisfied at any window height. */
.page-tail {
  /* The LEAST the sign-off gets to type across. The document ends here, so
     this is the only scroll it will ever have; the spacer that carries it grows
     past this wherever the window is taller than the content. */
  --tail-hold: clamp(60px, 12svh, 170px);
  /* One window so the bottom of the page shows nothing of the section above,
     PLUS the hold — at exactly one window there is no scroll left once the
     plate has pinned, the typing window comes out as zero, and since that
     window is a division it is a NaN rather than a slow print. Nothing at all
     appeared. */
  min-height: calc(100svh + var(--tail-hold));
  display: flex;
  flex-direction: column;
}
#team {
  /* Whatever the footer does not need. */
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  /* NOT `+ var(--head-top)`. That put the plate's natural position BELOW its
     own sticky offset, and a sticky element is only pulled toward `top` when it
     would otherwise go above it — so the last header never pinned at all. The
     clearance the extra padding used to buy comes from the plate pinning
     higher instead. */
  padding-top: var(--sec-pad);
  /* None at the bottom: the footer below has its own, so this was the same gap
     charged twice — and on a short screen it was the last thing between the
     grid and the header pinned above it. */
  padding-bottom: 0;
}

.team-grid {
  display: grid;
  gap: clamp(16px, 3vw, 40px);
  grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr));
  max-width: 900px;
  /* Last in the section, directly above the footer: the spacer above it takes
     whatever the window has spare, so this lands at `V - grid - footer` from
     the top of the final window, whatever that window is. An auto margin did
     the same job only when the container happened to have free space — a
     different answer on every screen.

     `width` is not decoration. Auto inline margins on a flex item override the
     default `stretch`, so this shrank to fit its own content — and an
     `auto-fit` grid with nothing to fill collapses to a single column, which is
     why two of the three gators vanished. */
  width: 100%;
  margin: 0 auto;
}
/* The scroll the sign-off types across, and the slack for everything else.
   Flexible, so a tall window gives the typing MORE room rather than opening a
   hole between the grid and the footer. */
.team-hold {
  flex: 1 1 auto;
  min-height: var(--tail-hold);
}

.team-card { text-align: center; }

.team-art {
  /* It is an `<a>` now, and an inline box ignores width, aspect-ratio and auto
     margins — the frame would collapse to the image and stop centring. */
  display: block;
  /* As big as the tail allows, which is a solved number rather than a nudged
     one — `maxart.py` prints the room per window. A flat `svh` coefficient is
     held down by the SHORTEST window, because the room grows faster than the
     window does: the plate and the footer do not scale with V, so a 640px
     screen has 28% of itself spare where a 1440px screen has 50%. The offset
     tracks that. */
  width: min(600px, 60vw, calc(45svh - 110px));
  margin: 0 auto 16px;
  aspect-ratio: 1;
  overflow: hidden;
  background: #000;
  border: 1px solid var(--line);
}
.team-art img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform 0.5s cubic-bezier(0.2, 0.7, 0.2, 1);
}
.team-card:hover .team-art img { transform: scale(1.05); }

/* No confirmed PFP yet — a neutral placeholder rather than someone else's
   gator standing in for them. */
.team-art.pending {
  display: grid;
  place-items: center;
  background: rgba(51, 255, 51, 0.04);
  border-style: dashed;
}
.team-art-placeholder { font-size: 44px; opacity: 0.25; }

/* Not uppercased: these are people's names, and the camel case in
   SuperTopSecret / PaperD / Kthings is part of how they're written. */
.team-name {
  margin: 0;
  color: var(--primary);
  font-size: clamp(15px, 1.8vw, 20px);
  font-weight: 700;
  text-transform: none;
  letter-spacing: 0.04em;
}
.team-role {
  margin: 4px 0 0;
  color: var(--dim);
  font-size: 11px;
  letter-spacing: 0.2em;
  text-transform: uppercase;
}
.team-link {
  display: inline-block;
  margin-top: 10px;
  font-size: 12px;
  letter-spacing: 0.08em;
  border-bottom: 1px solid transparent;
  transition: border-color 0.3s;
}
.team-link:hover { border-bottom-color: var(--primary); }

/* ------------------------------------------------- 07 gators in the wild */

.tiles {
  display: grid;
  gap: 4px;
  grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr));
  margin-top: clamp(28px, 5vh, 56px);
}
.tile {
  position: relative;
  /* An <a> now — needs a block box for aspect-ratio and the image fill. */
  display: block;
  overflow: hidden;
  background: #000;
  aspect-ratio: 4 / 3;
  color: inherit;
  text-decoration: none;
}
/* It leads somewhere, so it has to look like it does. */
a.tile::after {
  content: '↗';
  position: absolute;
  top: 10px;
  right: 12px;
  z-index: 2;
  color: var(--primary);
  font-size: 13px;
  opacity: 0;
  transform: translateY(-3px);
  transition: opacity 0.3s, transform 0.3s;
}
a.tile:hover::after,
a.tile:focus-visible::after { opacity: 1; transform: none; }
a.tile:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.tile img,
.tile video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  opacity: 0.62;
  transition: opacity 0.4s, transform 0.6s cubic-bezier(0.2, 0.7, 0.2, 1);
}
.tile:hover img,
.tile:hover video { opacity: 0.85; transform: scale(1.04); }
.tile-text {
  position: absolute;
  inset: auto 0 0 0;
  padding: 16px;
  background: linear-gradient(to top, rgba(0, 0, 0, 0.85), transparent);
}
.tile-name {
  display: block;
  color: var(--primary);
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.16em;
  text-transform: uppercase;
}
/* .tile-line is gone: the tiles carry the name only and link to their post. */

/* ------------------------------------------------- shared adventure prose */

/* The Collection lede, the Return to Swamp mint text and The Office copy all
   used different sizes and measures, so the three sections didn't read as one
   voice. This is the single treatment: a wider measure at a smaller size, so
   the same words occupy far less height than the old 30px centred column.
   Monospace is already the page font, which suits copy written as second-person
   adventure prose ("You find yourself in a dimly-lit office…"). */
.quest-text {
  max-width: min(980px, 92vw);
  margin: clamp(16px, 2.4vh, 26px) auto 0;
  padding: 0;
  font-size: clamp(12.5px, 1.15vw, 15px);
  line-height: 1.85;
  letter-spacing: 0.015em;
  color: var(--body);
  text-align: center;
  white-space: pre-line;
}

/* The Office copy ends by describing a blinking cursor, so it gets one. */
.quest-cursor::after {
  content: '';
  display: inline-block;
  width: 0.55em;
  height: 1.05em;
  margin-left: 0.35em;
  vertical-align: -0.18em;
  background: var(--primary);
  animation: quest-blink 1.1s step-end infinite;
}
@keyframes quest-blink { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
  .quest-cursor::after { animation: none; }
}

/* ------------------------------------------------------- station headers */

/* GTV and WG8R both lead with a logo instead of a typographic title. */
/* ---- station idents ----
   GTV and WG8R are their own properties, not chapters of the About page, so
   each gets a full-bleed band in its own colour temperature with the mark
   locked up beside the wordmark on one line. */
.station-band {
  margin: 0 calc(var(--gut) * -1) clamp(26px, 4.5vh, 52px);
  padding: clamp(24px, 4.5vh, 50px) var(--gut) clamp(18px, 3vh, 34px);
  border-top: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
}
.station-lockup {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: clamp(14px, 2vw, 32px);
  /* Wraps to stacked on a narrow screen rather than squeezing the wordmark. */
  flex-wrap: wrap;
}
.station-lockup .sec-title {
  margin: 0;
  text-align: left;
}
.station-mark { flex: 0 0 auto; }
.station-mark img {
  display: block;
  width: auto;
  /* Tracks the title's own clamp(30px, 6vw, 76px) at about 1.25x, so the
     lockup keeps its proportions the whole way down. */
  height: clamp(38px, 7.5vw, 95px);
}
/* The circle reads larger than a rectangle at equal height, and this one has
   its own internal margin, so it is set a little bigger to balance. */
#wg8r-logo img { height: clamp(52px, 10vw, 128px); }

/* GTV: the cool end. Scanlines and a green wash off the top edge, picking up
   the CRT in the mark and the set below it. */
#gtv .station-band {
  background:
    repeating-linear-gradient(to bottom,
      rgba(255, 255, 255, 0.03) 0 1px, transparent 1px 3px),
    radial-gradient(120% 130% at 50% 0%, rgba(51, 255, 51, 0.09), transparent 62%),
    #0d0f11;
}
/* The mark is black-filled with white letters, which sinks into a dark ground.
   A faint glow lifts it off without putting a visible box around it. */
#gtv-logo img { filter: drop-shadow(0 0 18px rgba(51, 255, 51, 0.22)); }

/* WG8R: the warm end. The badge is printed cream, so the band leans amber —
   two adjacent sections that read as two different broadcasters. */
#wg8r .station-band {
  background:
    radial-gradient(120% 130% at 50% 0%, rgba(226, 198, 132, 0.12), transparent 62%),
    #15120d;
}
#wg8r .sec-title { color: #e8d6a8; }
#wg8r .sec-kicker { color: #a2916f; }

/* ------------------------------------------------------------ WG8R tracks */

.tracks {
  display: grid;
  gap: clamp(18px, 3vw, 36px);
  grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr));
  max-width: 1200px;
  margin: clamp(28px, 5vh, 56px) auto 0;
}
.track { display: flex; flex-direction: column; }

/* 16:9 so the embed and the poster occupy exactly the same box — no reflow
   when the iframe replaces the art. */
.track-frame {
  position: relative;
  aspect-ratio: 16 / 9;
  background: #000;
  border: 1px solid var(--line);
  overflow: hidden;
}
.track-frame iframe,
.track-frame img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
  display: block;
}
/* Album art is square, so it's cropped to the 16:9 plate rather than letterboxed. */
.track-frame img { object-fit: cover; }

/* Click-to-load: the YouTube iframe is only inserted on click, so the page
   doesn't pull two third-party players on every visit. */
.track-play {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: grid;
  place-items: center;
  border: 0;
  background: linear-gradient(to top, rgba(0,0,0,0.55), rgba(0,0,0,0.15));
  cursor: pointer;
  transition: background 0.3s;
}
.track-play:hover { background: linear-gradient(to top, rgba(0,0,0,0.4), rgba(0,0,0,0.05)); }
.track-play span {
  display: grid;
  place-items: center;
  width: 64px;
  height: 64px;
  border: 2px solid var(--primary);
  border-radius: 50%;
  color: var(--primary);
  font-size: 20px;
  padding-left: 4px;                 /* optical centring of the triangle */
  background: rgba(0, 0, 0, 0.45);
  transition: transform 0.3s, background 0.3s;
}
.track-play:hover span { transform: scale(1.08); background: rgba(0, 0, 0, 0.65); }

.track-meta {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  margin-top: 12px;
}
.track-title {
  margin: 0;
  color: var(--primary);
  font-size: clamp(14px, 1.6vw, 19px);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.02em;
}
.track-artist {
  color: var(--dim);
  font-size: 11px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  white-space: nowrap;
}

/* Icons only — the service names are gone, so each mark carries its own brand
   colour to be recognised by. The accessible name moved to aria-label. */
.track-links {
  display: flex;
  gap: clamp(12px, 1.6vw, 22px);
  margin-top: 12px;
}
/* The marks are the platforms' own artwork now, so no border or plate around
   them — a ring would fight three logos that already carry their own shape.
   Sized for a comfortable tap target rather than a decorative dot: 48px+ on a
   pointer, 56px on touch, which clears the 44px minimum. */
.track-link {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: clamp(48px, 5vw, 60px);
  height: clamp(48px, 5vw, 60px);
  border-radius: 50%;
  transition: transform 0.25s var(--ease-out, ease), filter 0.25s;
}
.track-link img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  display: block;
  /* Lifts the marks off a dark ground without a plate behind them. */
  filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.5));
}
.track-link:hover { transform: translateY(-3px) scale(1.06); }
.track-link:active { transform: translateY(-1px) scale(1.02); }
.track-link:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: 4px;
}
@media (pointer: coarse) {
  .track-link { width: 56px; height: 56px; }
}

/* ---------------------------------------------------------------- 08 footer */

/* Shorter than a normal section: it has to share one window with the team
   grid and the header pinned above both. */
.sec-footer {
  /* No ground of its own. The page is one continuous gradient and this was the
     only thing cutting a black rectangle out of it. */
  text-align: center;
  padding-block: clamp(26px, 3.5vh, 48px);
}
/* Same reasoning as .team-art: fixed at 120px it costs a short window as much
   as a tall one, and it is the tallest single thing in the footer. */
/* The asset is gold on a black matte and there is no transparent version in
   brand/. `screen` drops black to nothing over a dark ground, so the matte
   stops showing as a square. A real transparent coin would be better and this
   line comes straight back out when there is one. */
.coin {
  margin: 0 auto;
  height: min(120px, 11svh);
  width: auto;
  mix-blend-mode: screen;
}
.thanks {
  margin: 28px 0 0;
  font-size: clamp(16px, 2.2vw, 26px);
  color: var(--body);
}
.credits { margin: clamp(18px, 2.6vh, 32px) 0 0; color: var(--dim); font-size: 12px; letter-spacing: 0.12em; }
.footer-links {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 8px 20px;
  margin-top: 20px;
}
.footer-links a { font-size: 12px; letter-spacing: 0.16em; text-transform: uppercase; opacity: 0.8; }
.footer-links a:hover { opacity: 1; }

/* A short window has to fit the team grid AND the whole footer under the
   pinned header — that is the ask for the bottom of the page. On tall screens
   there is room to spare; below about 820px the air around the footer is the
   first thing that should give, and none of it is load-bearing. */
@media (max-height: 820px) {
  .team-grid { margin-top: clamp(14px, 2vh, 24px); }
  .sec-footer { padding-block: clamp(18px, 2.4vh, 30px); }
  .thanks { margin-top: 16px; }
  .credits { margin-top: 14px; }
  .footer-links { margin-top: 14px; }
}

/* ---------------------------------------------------------------- reveal */

/* The only scroll motion in this pass: a plain fade-up. */
.reveal { opacity: 0; transform: translateY(18px); transition: opacity 0.7s ease, transform 0.7s ease; }
.reveal.in { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) {
  .reveal { opacity: 1; transform: none; transition: none; }
  /* The cue stays — it's the affordance; only the bobbing goes. */
  .scroll-cue-arrow { animation: none; opacity: 1; }
  html { scroll-behavior: auto; }
}

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

@media (max-width: 640px) {
  .masonry { columns: 2; }
  /* The drawn buttons are ~8px tall on a phone. Grow the invisible touch area
     around each one rather than the highlight, so the target is thumb-sized
     while the press still lights up only the button in the photo. */
  .tv-hit::before { inset: -11px -7px; }
}

/* ============================================================
   PASS 2 — FLOW
   
   The brief the file header left open: "Flow and transitions come in pass 2."
   
   Motion here is scroll-linked, not triggered. An IntersectionObserver fires
   once and plays a fixed timeline, which is why observer-based reveals feel
   detached from the scroll — they run at their own speed regardless of yours.
   These are driven by `animation-timeline: view()`, so progress IS scroll
   position: reverse the wheel and the motion reverses with you, which is the
   quality the reference sites have.
   
   They also run off the main thread, which matters on a page carrying this
   much video. Every animated property is transform or opacity — nothing here
   triggers layout.
   
   The observer path stays as the fallback for browsers without scroll
   timelines; inside @supports it is neutralised so the two never fight.
   ============================================================ */

:root {
  /* Decelerating, no overshoot. Motion should look like it is settling into
     place, never springing. */
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
  --rise: 34px;
}

@keyframes flow-rise {
  from { opacity: 0; transform: translate3d(0, var(--rise), 0); }
  to   { opacity: 1; transform: none; }
}
/* Media settles up to full size rather than sliding — the way a product shot
   resolves. The scale is small on purpose; anything more reads as a zoom. */
@keyframes flow-settle {
  from { opacity: 0; transform: translate3d(0, 26px, 0) scale(0.955); }
  to   { opacity: 1; transform: none; }
}
@keyframes flow-fade {
  from { opacity: 0; }
  to   { opacity: 1; }
}
@keyframes scrim-deepen {
  from { opacity: 1; }
  to   { opacity: 1.6; }
}
/* Parallax: the plate travels against the scroll, so a full-bleed image reads
   as being behind the page rather than pasted onto it. */
@keyframes flow-parallax {
  from { transform: translate3d(0, -3.5%, 0) scale(1.1); }
  to   { transform: translate3d(0, 3.5%, 0) scale(1.1); }
}
@keyframes flow-in-left {
  from { opacity: 0; transform: translate3d(-26px, 0, 0); }
  to   { opacity: 1; transform: none; }
}
@keyframes flow-in-right {
  from { opacity: 0; transform: translate3d(26px, 0, 0); }
  to   { opacity: 1; transform: none; }
}

@supports (animation-timeline: view()) {
  /* The observer path is inert here — the animations below own these
     properties, and a stale `.reveal` opacity:0 would fight the fill. */
  .reveal,
  .reveal.in { opacity: 1; transform: none; transition: none; }

  /* ---- the common case: anything that should arrive as you reach it ---- */
  .sec-title,
  .sec-kicker,
  .quest-text,
  .sec-body,
  .carousel-caption,
  .station-lockup {
    /* `.hat-head` was in this list and does not need it: the title inside it
       carries the shared band arrival, and a second `flow-rise` on the wrapper
       only fought it. */
    animation: flow-rise linear both;
    animation-timeline: view();
    /* Finishes well before centre, so nothing is still arriving once you have
       stopped to read it. */
    animation-range: entry 8% cover 26%;
  }

  /* Media gets the settle instead of the rise. */
  .carousel-stage,
  .office-banner,
  .tv,
  .track-frame,
  .team-art {
    animation: flow-settle linear both;
    animation-timeline: view();
    animation-range: entry 6% cover 30%;
  }


  /* ---- the office banner travels against the scroll ----
     Named timeline, declared on .office-banner. See the note there: the frame
     clips, which makes it a scroll container, and view() on the image inside
     it resolves to a box that never scrolls. */
  .office-banner img {
    animation: flow-parallax linear both;
    animation-timeline: --office;
    animation-range: cover 0% cover 100%;
  }

  /* ---- the carousel closes in from both sides ---- */
  #carousel-side-prev {
    animation: flow-in-left linear both;
    animation-timeline: view();
    animation-range: entry 10% cover 32%;
  }
  #carousel-side-next {
    animation: flow-in-right linear both;
    animation-timeline: view();
    animation-range: entry 10% cover 32%;
  }

  /* ---- grids ----
     Each item carries its own view timeline, so a grid staggers by geometry on
     its own. --i adds a small offset across a row so items landing on the same
     line do not resolve in lockstep. It is set per item in about.js. */
  /* Deliberately NOT .strip img: those live inside .band-track, which is
     translating continuously on its own animation. A view() timeline on a child
     of a moving marquee competes with that transform for the same property and
     can leave an image parked at zero opacity. The band already has motion —
     it does not need a reveal too. */
  .masonry img,
  .tiles > div,
  .track,
  .team-card {
    animation: flow-rise linear both;
    animation-timeline: view();
    animation-range: entry calc(4% + (var(--i, 0) * 2.5%)) cover 28%;
    --rise: 26px;
  }

  /* Band mosaics are backdrops; they should wash in, not travel. */
  .band-mosaic {
    animation: flow-fade linear both;
    animation-timeline: view();
    animation-range: entry 0% entry 90%;
  }

  /* The label plate leads its band in slightly. */
  .band-label {
    animation: flow-rise linear both;
    animation-timeline: view();
    animation-range: entry 12% cover 22%;
    --rise: 20px;
  }

  /* The footer coin is the last thing on the page; let it arrive rather than
     appear. */
  .sec-footer .coin {
    animation: flow-settle linear both;
    animation-timeline: view();
    animation-range: entry 15% cover 45%;
  }
}

/* Word-level reveal for the three adventure-prose blocks. The spans are built
   in about.js; without it the text renders exactly as before. */
@supports (animation-timeline: view()) {
  .quest-text.by-word {
    /* The block no longer animates as a unit — its words do. */
    animation: none;
    opacity: 1;
    transform: none;
  }
  .quest-text.by-word { white-space: normal; }
  .quest-text.by-word .w {
    display: inline-block;
    animation: flow-rise linear both;
    animation-timeline: view();
    /* Each word trails the one before it by a hair. --i is capped at 12 in JS:
       the offset has to stay below the range's end, or the start would land
       after the finish and the word would never resolve. */
    animation-range: entry calc(6% + (var(--i, 0) * 0.8%)) cover 24%;
    --rise: 14px;
  }
}

/* None of this is load-bearing: every element is legible and in place without
   it, so it all comes off together. */
@media (prefers-reduced-motion: reduce) {
  .sec-title, .sec-kicker, .quest-text, .sec-body,
  .carousel-caption, .station-lockup, .hat-head, #team-headline,
  .carousel-stage, .office-banner, .office-banner img, .tv,
  .track-frame, .team-art,
  #carousel-side-prev, #carousel-side-next,
  .masonry img, .tiles > div, .track, .team-card,
  .band-mosaic, .band-label, .sec-footer .coin,
  .quest-text.by-word .w {
    animation: none !important;
    opacity: 1 !important;
    transform: none !important;
  }
}

/* ---- WG8R: our banner is the header, their card is the player ----
 *
 * The banner is a printed lockup on a cream ground, so it runs full-bleed
 * against the dark page — the contrast is the point, and cropping it into a
 * band would cut the cattails off the ends.
 *
 * The Spotify card sits underneath at the same measure as the tracks, so the
 * section reads: name, then the thing that plays, then the singles.
 */
/* ---- WG8R: arrive through fog, print on the swamp, lock, hand off ----
 *
 * Four beats, in one scroller:
 *
 *   1. fog        the page dissolves into weather, then into the picture
 *   2. print      the lockup arrives ON the photograph, on its two reed stands
 *   3. lock       the lockup reaches the top of the window and stays there
 *   4. hand off   the photograph goes, a fog bank stays behind the ink, and
 *                 the player rises into place underneath
 *
 * `.station-run` is the scroller and owns the timeline. `.station-plate` is
 * the picture and only the picture, absolutely positioned at the top of the
 * run so it never pushes anything down. `.station-lock` is what pins, and it
 * carries the artwork, the fog bank and the player TOGETHER — three things
 * that must stay in register while they travel, which is much easier to get
 * right as one object than as three that have to agree.
 */
.station-run {
  position: relative;
  margin: 0 calc(var(--gut) * -1);
  /* The photograph's band. A token because the artwork's offset and the run's
     total height are both fractions of it and must not drift apart. */
  --band: min(100vw, 150svh);
  /* How wide the artwork settles to once it is locked.

     Full-bleed is right while it is printed on the picture — that is what puts
     its drawn cattails on the photographed ones — but full-bleed PINNED is 43%
     of the viewport's WIDTH in height: 560px on a 1311x800 laptop, 820px at
     1920. Against the player (a 152px Spotify bar plus ~64px of margin) that
     leaves nothing, or less than nothing.

     The artwork is 2.333:1, so a width of 89svh is a height of ~38svh, and the
     locked stack comes to ~38svh + 216px — comfortable in one screen at every
     size checked.

     A bare length, not `min(100%, 89svh)`: this value is ANIMATED, and the
     keyframe interpolates it against `100%`. Mixing a percentage with one
     length is a plain calc interpolation; mixing it with a min() of both is
     needlessly more to ask of the engine for no gain, because `max-width: 100%`
     on the image already does the capping. That cap is what makes a phone
     safe, where 89svh is wider than the screen: the artwork simply never
     shrinks, and does not need to at 167px tall. */
  --lock-w: 89svh;
  /* Where the lock parks. A token because the PICTURE has to pin at exactly
     the same scroll position as the artwork, and it does that by pinning at
     this minus its own offset — if the two ever disagree the composite slides
     apart while you watch it. */
  --lock-top: clamp(8px, 3svh, 32px);
  /* The band, plus the hold the lock needs. Sized so the hold runs out shortly
     after the player has landed: any longer and the section reads as a dead
     patch you have to scroll through before the singles arrive. min- rather
     than a fixed height so the player can never be clipped by it. */
  min-height: calc(var(--band) + 55svh);
  /* Establishes a block formatting context, so the lock's top margin offsets
     it INSIDE the run instead of collapsing through and moving the run.
     Deliberately not `overflow`, which would make this a scroll container and
     kill the timeline (trap 1). */
  display: flow-root;
  view-timeline-name: --wg8r;
  view-timeline-axis: block;
}

/* The section's own top padding would sit a flat dark band between the
   previous section and the weather, and the eye reads the top of that band as
   the edge. The feather IS the transition, so it starts immediately. */
#wg8r { padding-top: 0; }

/* ---- 1-2: the picture ---- */
/* Sticky, not absolute, and IN FLOW — the two are connected.
 *
 * While the artwork was pinned and the picture was not, the picture kept
 * scrolling out from under it: by the time the lockup had settled it was
 * sitting over the bottom of the photograph, where the drawn cattails line up
 * with open water instead of the photographed reeds. The composite came apart
 * precisely when it was most on show.
 *
 * So the picture pins too, at the same instant, and simply fades where it
 * stands. `top` is the lock's own offset minus the lock's distance down the
 * band, which is what makes "the same instant" exact rather than approximate.
 *
 * Being in flow costs the plate a band's worth of height, which the lock takes
 * straight back with a negative margin below. That is deliberate: sticky only
 * works in flow, and the alternative — absolute, so it takes no space — is
 * what caused the drift in the first place. */
.station-plate {
  position: sticky;
  top: calc(var(--lock-top) - var(--band) * 0.25);
  height: var(--band);
}
.station-plate > * {
  position: absolute;
  inset: 0;
  margin: 0;
}
.station-photo {
  line-height: 0;
  /* The clip lives here rather than on the plate: only the photograph scales,
     and keeping the plate out of the scroll-container business leaves it free
     to hold positioned children (trap 1). */
  overflow: hidden;
  /* Eased, not linear, and long. The top of the crop is sky, brighter than the
     page ground, and a straight alpha ramp against a dark page reads as an
     onset — you can see the picture start. Holding the early part of the ramp
     low makes it condense out of the page instead. Five stops up over 28%,
     which on a ~1200px band is ~340px of ramp. */
  -webkit-mask-image: linear-gradient(
    to bottom, transparent 0%, rgba(0, 0, 0, 0.12) 7%,
    rgba(0, 0, 0, 0.38) 13%, rgba(0, 0, 0, 0.70) 19%,
    rgba(0, 0, 0, 0.92) 23%, #000 28%,
    #000 82%, rgba(0, 0, 0, 0.55) 91%, rgba(0, 0, 0, 0.18) 97%,
    transparent 100%);
  mask-image: linear-gradient(
    to bottom, transparent 0%, rgba(0, 0, 0, 0.12) 7%,
    rgba(0, 0, 0, 0.38) 13%, rgba(0, 0, 0, 0.70) 19%,
    rgba(0, 0, 0, 0.92) 23%, #000 28%,
    #000 82%, rgba(0, 0, 0, 0.55) 91%, rgba(0, 0, 0, 0.18) 97%,
    transparent 100%);
}
.station-photo img {
  width: 100%;
  height: 100%;
  display: block;
  /* WHERE the crop sits, measured off the picture (fractions of its height):
     pale sky to ~10%, cypress crowns and Spanish moss 10-35%, the mid-ground
     treeline and the sun glow 35-50%, the mist bank 48-56% with the waterline
     at ~53%, and the FOREGROUND REEDS — the two cattail stands the artwork is
     aligned to — from ~34% down to where they root at ~82-86%.

     `object-position` is a fraction of the OVERFLOW, not of the picture, so a
     low value pins the window near the top of the image and lets the band's
     height decide how far down it reaches. At 12% and this band that is rows
     ~1-92% on a laptop and the whole picture on a phone: always opening on
     sky, always reaching the reeds. */
  object-fit: cover;
  object-position: 50% 12%;
}

/* ---- fog ----
 *
 * The seam used to be a straight alpha ramp: the picture simply became less
 * transparent, and because what it was revealing was STRUCTURE — crowns, moss,
 * branches — the eye could still find where it began. Fog gives it something
 * featureless to arrive through, so there is nothing legible in the ramp.
 *
 * Screen rather than a normal layer, because fog only ever adds light; a plain
 * translucent fill would darken the picture where it overlaps and read as
 * grime. Screen's identity is black, so the transparent stops cost nothing.
 *
 * The colours are sampled off the photograph itself — the mist bank is #ba9974
 * and the upper sky #b09b7f — so this is the same weather, not a grey wash
 * laid over it. */
.station-fog {
  pointer-events: none;
  mix-blend-mode: screen;
  /* THE SEAM. The radial below is centred at 26% with a vertical radius of
     46%, which puts the band's very first row 56% of the way along the
     gradient ray — still at alpha ~0.18, not 0. Screen-blending 18% of a warm
     tan onto the page ground in a single pixel step is a ruled line, and it
     was one.
     A mask fixes it at the source instead of by re-tuning stops that have to
     stay put for the rest of the band: whatever the gradient does, the layer
     is worth nothing at the top edge.

     SHORT, though. At full only by 15% of the band, the first 200-odd pixels
     of the run were bare page ground — a grey gap where the mist should be.
     Full by 4% instead, which is still ~60px of ramp at a laptop width, so the
     edge stays a transition rather than the line it was. */
  -webkit-mask-image: linear-gradient(
    to bottom, transparent 0%, rgba(0, 0, 0, 0.5) 1.5%, #000 4%,
    #000 72%, transparent 100%);
  mask-image: linear-gradient(
    to bottom, transparent 0%, rgba(0, 0, 0, 0.5) 1.5%, #000 4%,
    #000 72%, transparent 100%);
  background:
    radial-gradient(135% 46% at 50% 26%,
      rgba(186, 153, 116, 0.46) 0%,
      rgba(150, 126, 98, 0.24) 48%,
      rgba(120, 102, 82, 0.07) 74%,
      transparent 92%),
    linear-gradient(to bottom,
      transparent 0%,
      rgba(176, 155, 127, 0.16) 9%,
      rgba(176, 155, 127, 0.26) 18%,
      rgba(160, 140, 114, 0.22) 32%,
      rgba(130, 114, 94, 0.10) 52%,
      transparent 70%);
}

/* ---- 3-4: what pins ---- */
.station-lock {
  position: sticky;
  /* Not flush. It reads as locked either way, and a hairline of ground above
     it keeps the artwork from looking cropped by the window edge. */
  top: var(--lock-top);
  /* Lands on the reeds, at 25% of the band. The plate is in flow now and has
     already spent a full band of height, so this pulls back the other 75%
     rather than pushing down from zero — same place, and it stays the same
     place if the band's height changes.

     25% is where the artwork's drawn cattails sit on the photographed ones.
     The two sets cannot be matched in SCALE — the photograph's reeds span ~48%
     of the image height, the drawn ones ~24% of a full-width banner, so
     root-to-root would need a banner twice the width of the band — so they are
     aligned at the TIPS and allowed to diverge below, which is where the eye
     reads the pairing anyway. */
  margin-top: calc(var(--band) * -0.75);
}

/* The weather that stays. Once the photograph has gone the artwork is black
   ink on a dark page, i.e. invisible, so something has to remain behind it.
   A fog bank is the honest answer — it is what the section is made of — and it
   doubles as the reason the lockup still looks like it is somewhere rather
   than floating on a flat ground.

   Wider than it is tall and bled well past the artwork on every side, so it
   reads as weather rather than as a panel with edges. */
.station-haze {
  position: absolute;
  left: -10%;
  right: -10%;
  top: -30%;
  bottom: -55%;
  z-index: 0;
  pointer-events: none;
  opacity: 0;
  background:
    radial-gradient(58% 46% at 50% 44%,
      rgba(206, 184, 150, 0.78) 0%,
      rgba(168, 149, 120, 0.44) 42%,
      rgba(110, 99, 82, 0.16) 68%,
      transparent 86%);
}

/* The artwork. No blend mode and no filter: it is a real knockout now (the
   paper is removed in build-media.py), so it composites normally and can
   therefore be pinned. The previous `mix-blend-mode: multiply` could not —
   `position: sticky` creates a stacking context, blending would have been
   skipped, and the cream paper would have rendered as a solid block. */
.station-lock .station-banner {
  position: relative;
  z-index: 1;
  margin: 0;
  line-height: 0;
}
.station-lock .station-banner img {
  display: block;
  width: 100%;
  max-width: 100%;
  height: auto;
  margin: 0 auto;
}
/* Width, not transform. A transform would leave the sticky box at its old
   height, so the artwork would shrink visually while the layout kept a
   560-820px hole under it and the player stayed off the bottom of the screen.
   Animating width with `height: auto` shrinks the real box, so the pinned
   stack stays exactly as tall as what you can see. */

.station-lock .station-card {
  position: relative;
  z-index: 1;
}

@supports (animation-timeline: view()) {
  /* The picture condenses out of the fog, holds while the artwork prints onto
     it, and leaves once the lock has taken hold. The push-in runs the whole
     way so a still photograph never sits dead on the screen. */
  .station-run.has-photo .station-photo {
    animation: plate-photo linear both;
    animation-timeline: --wg8r;
    animation-range: cover 3% cover 60%;
  }
  /* The artwork arrives late, on purpose: the scene has to be worth looking at
     on its own first. On a 1311x800 laptop, in scroll past the top of the run:
        s=0     the band's top reaches the top of the window
        s=268   the band stops — it pins here, and this is as far DOWN the
                picture as the viewer ever gets to see (band rows 268-1068,
                which is the reeds, whole)
     So "the image is filling the page and you have reached the bottom of it"
     is s=268, cover ~44%. This range straddles it: it starts at s=152, with
     the picture filling the screen and nothing on it, and is finished at s=322.
     The clean run-in is now ~950px of scroll. It was ~340px at the original
     14-30%, where `cover 30%` fell 68px BEFORE the band even reached the top of
     the window — the swamp was never once seen on its own.

     THE UPPER BOUND IS NOT A PREFERENCE. The photograph starts fading at cover
     ~42.9% and the artwork starts settling at 44%. Push the print-on much past
     46% and the lockup arrives onto a scene that is already half gone, which
     costs the one thing the alignment is for: the drawn cattails landing on the
     photographed ones while both are at full strength. At 46% the picture is
     ~18% faded and the settle ~12% in — both still negligible. Later than this
     needs the fade moved too, and the fade is bounded in turn by the plate
     losing its own pin at ~62%. */
  .station-run.has-photo .station-banner {
    animation: plate-art linear both;
    animation-timeline: --wg8r;
    animation-range: cover 39% cover 46%;
  }
  /* Beat 4, all of it inside the pin.
     On a 1311x800 laptop the lock engages at cover ~44%, the picture stops
     being able to hold its own pin at ~62% (it can only stay put for as long
     as the run is taller than the band), and the singles start to show at the
     bottom of the screen at ~67%. Everything below is fitted into that: the
     picture must be GONE before 62% or it will visibly start moving again,
     and the player must have landed before 67% or the singles arrive first. */
  .station-run.has-photo .station-haze {
    animation: plate-haze linear both;
    animation-timeline: --wg8r;
    animation-range: cover 45% cover 58%;
  }
  .station-run.has-photo .station-banner img {
    animation: plate-settle linear both;
    animation-timeline: --wg8r;
    animation-range: cover 44% cover 60%;
  }
  .station-run.has-photo .station-card {
    animation: plate-player linear both;
    animation-timeline: --wg8r;
    animation-range: cover 52% cover 65%;
  }
}
/* Holds full until 70% of its range — cover ~43%, just before the pin — then
   goes. The push-in finishes as it leaves, so it settles rather than drifts. */
@keyframes plate-photo {
  0%   { opacity: 0; transform: scale(1.06); }
  22%  { opacity: 1; transform: scale(1.038); }
  70%  { opacity: 1; transform: scale(1.004); }
  100% { opacity: 0; transform: scale(1); }
}
@keyframes plate-art {
  from { opacity: 0; }
  to   { opacity: 1; }
}
@keyframes plate-haze {
  from { opacity: 0; }
  to   { opacity: 1; }
}
@keyframes plate-settle {
  from { width: 100%; }
  to   { width: var(--lock-w); }
}
@keyframes plate-player {
  from { opacity: 0; transform: translateY(26px); }
  to   { opacity: 1; transform: none; }
}

/* Without the photograph there is nothing to arrive through, nothing to print
   onto and nothing to hand off from. The run collapses to what this was before
   any of it: a banner, then the player. The artwork is the delivered JPEG in
   that case (about.js picks it), so it keeps its paper and its rules. */
.station-run:not(.has-photo) {
  min-height: 0;
  display: block;
}
.station-run:not(.has-photo) .station-plate,
.station-run:not(.has-photo) .station-haze { display: none; }
.station-run:not(.has-photo) .station-lock {
  position: static;
  margin-top: 0;
}

/* No rule across the band. `.station-banner` frames a standalone banner, and
   that is still right in the fallback above, but over the picture those 1px
   borders draw a line across it — the one thing this section was asked not to
   have. Scoped to the printed case, winning on specificity rather than on
   source order (trap 6). */
.station-run.has-photo .station-banner { border: 0; }

/* Motion declined: no sequence, so the section becomes the things it is made
   of, in order, all at rest. The photograph must NOT be hidden — the artwork
   is a knockout and black ink on the page's ground is invisible, so the
   picture is what makes it readable. Nothing pins. */
@media (prefers-reduced-motion: reduce) {
  .station-run {
    min-height: var(--band);
    view-timeline-name: none;
  }
  .station-run .station-plate { position: relative; top: auto; }
  .station-run .station-lock { position: static; }
  .station-run .station-photo,
  .station-run .station-fog,
  .station-run .station-banner,
  .station-run .station-card {
    animation: none !important;
    opacity: 1 !important;
    transform: none !important;
  }
  /* Stays out of the way: with the picture at full strength the ink reads
     against it, and a fog bank with nothing to explain it would just be a
     bright smear. */
  .station-run .station-haze { animation: none !important; opacity: 0 !important; }
  .station-run .station-banner img { width: 100% !important; }
}

.station-banner {
  margin: 0 calc(var(--gut) * -1);
  line-height: 0;
  border-top: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
}
.station-banner img,
.station-banner video {
  width: 100%;
  height: auto;
  display: block;
}
/* .station-banner-link went with the GTV plate — WG8R's banner is not a link.
   Re-add it here if one ever needs to be. */

/* ---- the dark run ----
 *
 * Hero, montage, title, television: from the end of the film to the far side
 * of the set the page never comes back up to daylight.
 *
 * ONE pin. Two pinned sections in a row cost a whole viewport of scrolling
 * between them — the first stage must unstick and travel out before the second
 * can travel in and stick — and with both of them black that was a screen and
 * a half of nothing. Here the stage holds the middle and its contents
 * cross-fade in place. Nothing travels, so there is nothing to sit through.
 */
.sec-darkrun {
  position: relative;
  padding: 0;
  /* No background of its own: the scroller paints the black, and the tail
     below it fades that black away to nothing so the page's own ground shows
     through. A black section here would sit behind the tail and defeat it. */
}
.run-scroller {
  position: relative;
  /* 100svh held for the pin, plus the 320svh the sequence runs over: the
     pull-back, three channel changes with time to watch each, and the sign-off.
     A marker at X% of the scroller crosses the bottom of the window at
     contain (4.2X - 100) / 320 — runcheck.py does that arithmetic. */
  height: 420svh;
  background: #000;
  view-timeline-name: --run;
  view-timeline-axis: block;
}
/* ---- the intro, held ----
 * Set one-way by js once the reader has been past the first channel. Every
 * piece of the arrival is pinned at the value it finished on, so scrolling
 * back up holds a television that does not move. Nothing here touches the
 * channels: those still step backwards with the markers, which is the point.
 *
 * `animation: none` drops each fill and lets the element fall back to its own
 * resting value — which is already the finished state for everything except
 * the picture's filter and the caption's width, so those two are restated. */
.run-stage.run-locked .run-layer.is-tv .tv,
.run-stage.run-locked .run-layer.is-tv .tv-frame:not(.tv-frame-mask),
.run-stage.run-locked .run-layer.is-tv .tv-led,
.run-stage.run-locked .run-layer.is-tv .tv-hit,
.run-stage.run-locked .run-layer.is-tv .tv-scan,
.run-stage.run-locked .run-layer.is-tv .tv-glare {
  animation: none;
}
.run-stage.run-locked .run-layer.is-tv .tv-screen video {
  animation: none;
  filter: saturate(1.16) contrast(1.09) brightness(0.98) blur(0.3px);
}
/* (A rule here used to hide the mute for good once the intro was locked. It
   does not go away any more — the page plays sound all the way down.) */
/* The line stays written. Only its exit still runs, so it can still leave
   with the television. */
/* (The freeze used to re-declare the caption's visibility animation here. At
   (0,3,0) it outranked `body.caption-written .run-caption` at (0,2,0), so it
   quietly reinstated the reversible version and the line faded out again on
   the way up — through two rounds of me adding rules instead of checking which
   one was winning. The body classes own the caption now, and nothing else
   touches it.) */

/* The typed line. Top left, and it goes when the television does.

   Positioned in the STAGE rather than fixed to the window: it belongs to this
   sequence, not to the document. It rides the pin while the set is on screen
   and leaves with it — a persistent header over the rest of the page was
   tried and was simply in the way.

   `overflow: hidden` on an animated width is the whole mechanism: the text is
   always there, and what changes is how much of it the box will show. */
.run-caption {
  position: absolute;
  z-index: 45;
  top: clamp(10px, 1.8vw, 20px);
  /* Clear of the prompt, which is the nav chevron wearing its `>` face. */
  left: calc(clamp(12px, 2vw, 26px) + 36px);
  width: 0;
  max-width: calc(100vw - 180px);
  /* Matched to the prompt's box so the two sit on one line. */
  height: 30px;
  line-height: 30px;
  overflow: hidden;
  white-space: nowrap;
  font-family: var(--mono);
  font-size: clamp(13px, 2.1vw, 22px);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--primary);
  /* The block cursor, at the clip edge where the next character lands.
     `content-box` matters: the page sets border-box on everything, and under
     that the caret would eat into the animated width and clip a character. */
  box-sizing: content-box;
  border-right: 0.62em solid var(--primary);
  text-shadow: 0 1px 10px rgba(0, 0, 0, 0.9), 0 0 3px rgba(0, 0, 0, 0.9);
  pointer-events: none;
}
@supports (animation-timeline: view()) {
  .run-caption {
    animation: run-caption linear both, run-caption-vis linear both,
               cap-blink 1.06s steps(1, end) infinite;
    animation-timeline: --run, --run, auto;
    /* Types once the set has landed and is FINISHED before the first channel
       change at 54% — which is also where the page stops scrolling back to,
       so the line is always complete there rather than frozen part-typed.
       The second range carries it on at 44% and off again by 96%. The blink
       is on the clock, not the scroll — a cursor that only blinked while you
       moved would be a progress bar, not a cursor — hence `auto` and
       `normal` in the third slot. */
    animation-range: contain 46% contain 54%, contain 44% contain 96%, normal;
  }
}
/* Typed, not wiped. Each burst carries its own steps() over its own slice of
   the range, which is what puts a hand on it: a quick opening, a hesitation
   before the capital, a run through the middle, a slow finish. */
@keyframes run-caption {
  0% { width: calc(var(--cap-total, 22ch) * 0.0000); animation-timing-function: steps(5, end); }
  14% { width: calc(var(--cap-total, 22ch) * 0.2273); animation-timing-function: steps(2, end); }
  19% { width: calc(var(--cap-total, 22ch) * 0.3182); animation-timing-function: steps(4, end); }
  38% { width: calc(var(--cap-total, 22ch) * 0.5000); animation-timing-function: steps(1, end); }
  42% { width: calc(var(--cap-total, 22ch) * 0.5455); animation-timing-function: steps(6, end); }
  66% { width: calc(var(--cap-total, 22ch) * 0.8182); animation-timing-function: steps(2, end); }
  80% { width: calc(var(--cap-total, 22ch) * 0.9091); animation-timing-function: steps(2, end); }
  100% { width: var(--cap-total, 22ch); }
}
/* On and off in one animation, because they are the same property and a
   second one would simply overwrite the first. It starts at nothing so the
   caret is not a green block sitting on the opening shot before there is
   anything to type. */
@keyframes run-caption-vis {
  0%    { opacity: 0; }
  3.8%  { opacity: 1; }
  80.8% { opacity: 1; }
  100%  { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .run-caption { animation: none; width: auto; border-right: 0; }
}

/* Above the layers, and the only thing on the stage that takes a click besides
   the set's own keys. (`#sound-toggle` was named here too and has not been a
   direct child of the stage since it moved onto the picture — the selector has
   been matching nothing. It carries its own z-index inside the screen.) */
.run-stage > .scroll-cue { z-index: 40; }

.run-stage {
  position: sticky;
  top: 0;
  height: 100svh;
  overflow: hidden;
  display: grid;
  place-items: center;
  background: #000;
}
/* Every layer covers the stage exactly, so they stack and cross-fade without
   any of them moving.

   Absolute rather than a stretched grid item: as a grid item this depended on
   the stage's implicit row stretching to the container's definite height, and
   on height: 100% resolving against that — several steps that all have to go
   right for a layer to simply be the size of the screen. This is the same
   statement in one step, and the set stopped sitting low the moment it went in. */
.run-layer {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
}
/* Stacking, stated rather than inherited from source order.
   The title has to sit ABOVE the television. The black cutout that shapes the
   tube is a whole cabinet knocked out to black, and its top edge is at 14svh
   while the title's second line starts around 18svh — so as the cutout faded
   in it swallowed the bottom half of the message and left "WELCOME TO" hanging
   on its own. Everything here is black on black, so drawing the title in front
   costs nothing visually and the message fades as one piece.
   By the time the cabinet is a photograph rather than a silhouette (64%), the
   title has been gone for two points. */
.run-layer.is-tv    { z-index: 2; }
/* Invisible layers must not swallow clicks meant for what is under them, and
   the set's buttons are transparent hit areas that would otherwise be live
   over the montage. They come alive when the set does. */
.run-layer.is-tv { pointer-events: none; }
.run-stage.is-on .run-layer.is-tv { pointer-events: auto; }

/* The title card is gone. The run opens on the film, pulls out of it into the
   television, and the station names itself on the picture — which is what the
   collection section's heading is for, now hidden. */

.run-layer.is-tv .tv {
  /* Height-bounded as well as width-bounded: the cabinet is 4:3, so a set
     sized only by width is taller than the stage on a short screen.

     106svh of width is 79.5svh of height, leaving about 10svh of air top and
     bottom. It was 96svh (72svh tall), which left 14svh at each end sitting
     empty. What stops it growing further is not the stage but the title: the
     tube's top edge has to clear two lines of WELCOME TO TOKENGATORS while
     both are on screen, which is why that moved up along with this. */
  width: min(1180px, 92vw, 106svh);
  max-width: none;
  margin: 0;
}
/* The tube sits at 40.75% / 47.52% of the cabinet, left of the speaker grille,
   so a centred CABINET does not put the PICTURE in the centre of the screen.
   This offset is a CONSTANT, never animated.

   Horizontal only. Nudging it down as well so the tube landed dead centre put
   the television itself low on the screen; the set reads better centred, with
   the picture riding a little above the middle — which is where a picture
   actually sits in a cabinet like this.

   The set used to slide into place as the cabinet arrived, and that was the
   wrong idea entirely: this is a television in a dark room where the screen is
   the only thing lit, and the room comes up around it. The screen is the
   anchor. Nothing in this sequence moves; things only become visible. */
.run-layer.is-tv .tv-cabinet {
  transform: translate(9.25%, 0);
}
/* The photograph again, knocked out to black. Invisible on the dark, but its
   aperture is a real hole in the PNG, so it masks the tube to the tube's own
   shape — bulged corners and all. That is what makes TUNE IN read as a monitor
   in a dark room instead of a rectangle of video, and it is the same mask the
   real photograph applies once it lands, because it IS the same photograph. */
.tv-frame-mask {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: auto;
  filter: brightness(0);
  z-index: 2;
}

/* ---- change the channel ----
   The pin holds for about 46svh after the set tunes. That time is not dead: as
   you keep scrolling it keeps tuning, and the key goes down each time it does.

   A line of text saying the buttons work would be a worse teacher than the
   buttons working. This way the control demonstrates itself, and you either
   take over or carry on scrolling. */
.tv-hit:active,
.tv-hit.is-pressed {
  background: rgba(0, 0, 0, 0.3);
  box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.5);
}
/* Markers for the channel changes during the hold, evenly through the stretch
   between the set tuning (contain 80%) and the pin letting go. */
/* Evenly spaced, about 13svh apart, with the same gap between the set tuning
   and the first of them — the channel it lands on gets the same beat to be
   watched as every one after it. At 88% the first change came 6.7svh after the
   tune, which cut the opening channel off almost as soon as it started. */
/* Spread, not bunched. These were at 90/94/98% — three channel changes inside
   8% of the scroller, about a quarter of a viewport between them, so at any
   normal scrolling speed all three happened at once and none of them could be
   watched. At 76/85/93% each channel gets roughly 27svh of scroll to itself.

   The scroller is 330svh and the pin is 230svh, so a marker at X% of the
   scroller crosses the bottom of the window at contain (3.3X - 100) / 230:
   76% -> 66%, 85% -> 78%, 93% -> 90%. The set finishes arriving at 58%, so the
   first channel lands well after it is all there, which is the brief. */
#gtv-ch-1 { top: 65%; }     /* -> contain 54%, the montage */
#gtv-ch-2 { top: 76%; }     /* -> contain 68% */
#gtv-ch-3 { top: 86%; }     /* -> contain 82% */
#gtv-signoff { top: 95%; }  /* -> contain 93%, back to snow on the way out */

/* (The "Watch on YouTube" row under the cabinet lived here. It is gone: the
   picture itself is the link now, and a real one — focusable, announced, and
   operable from the keyboard, which is the job the row was doing.) */

/* The marker js watches: it simply enters the viewport from the bottom, and
   that is the moment the set tunes.

   It used to be observed with rootMargin '-50% 0px -50% 0px' to catch it
   crossing the middle of the screen. That collapses the observer's root to a
   zero-height line, and a zero-area root never reports an intersection with a
   1px target — so the callback never ran, and the television sat on TUNE IN
   with its channel display reading "--" for the whole page. Plain viewport
   entry has no such edge.

   Placed so entry lands at 80% of the pin: 86% of a 330svh scroller is 284svh
   in, the viewport's bottom edge is one screen ahead of the scroll position,
   so it fires 184svh into a 230svh pin. */
.run-cue {
  position: absolute;
  top: 86%;
  left: 0;
  width: 1px;
  height: 2px;
}

/* Back out to daylight — AFTER the television, not over it.
   This wash used to sit at the bottom of the scroller, which is exactly where
   the stage is still pinned, so it greyed the set out while it was still
   playing. It belongs below the run: the black holds to the last frame of the
   sequence and the gradient happens in the tail. */
.run-tail {
  position: relative;
  /* Was 70px above and 130px below, on top of the next section's own padding —
     which put roughly 270px of nothing between the link and the next header,
     all of it mid-gradient and so all of it visibly empty. */
  padding: clamp(14px, 2.2svh, 30px) 0 clamp(22px, 3.5svh, 44px);
  background: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0) 100%);
}

@supports (animation-timeline: view()) {
  /* Everything moved ~26 points earlier when the montage stopped being a layer
     of its own: the run used to open on 24% of held film, and now it opens on
     the title. The SHAPE is unchanged — title, then the tube under it, then the
     title hands over, then the set — only the start of it is no longer waiting
     for a film to finish. The room that frees up goes to the channels at the
     other end, which is where it was always needed. */
  /* The tube has NO arrival at all. It is already the whole window when the
     run starts, because the set is scaled up around it, and the page is
     supposed to open ON THE FILM. A 0->4% ramp still meant opening on black
     for the first ~13% of a screen of scrolling, which is exactly the fault
     it looked like. The black knockout is opaque from the first frame too: at
     that scale it is a black field with one hole in it, which is the mask the
     pull-back wants. */
  /* THE PULL-BACK. --pull is measured in js: how much bigger the set has to be
     for its tube to cover the window. Scaled about the tube's centre, so the
     film stays put and the television arrives around it. */
  .run-layer.is-tv .tv {
    animation: tv-pull linear both;
    animation-timeline: --run;
    animation-range: contain 0% contain 46%;
  }
  /* Coming out of it. Nothing at the start — the opening shot is the film,
     clean — and a tube by the end. */
  .run-layer.is-tv .tv-screen video {
    animation: tv-warp linear both;
    animation-timeline: --run;
    animation-range: contain 0% contain 46%;
  }
  /* The scanlines and the corner glare arrive WITH the television, not before
     it. They used to be on from the first frame, which gave the reveal away:
     you cannot discover that somebody is watching a CRT if the picture has had
     scanlines on it the whole time. Late and fast, so they read as part of the
     set landing rather than as a slow wash. */
  .run-layer.is-tv .tv-scan,
  .run-layer.is-tv .tv-glare {
    animation: run-in linear both;
    animation-timeline: --run;
    animation-range: contain 30% contain 48%;
  }
  /* Then the set around it. Everything printed on the cabinet arrives at once
     — the photograph, the display and the keys are one object. */
  .run-layer.is-tv .tv-frame:not(.tv-frame-mask),
  .run-layer.is-tv .tv-led,
  .run-layer.is-tv .tv-hit {
    animation: run-set-in linear both;
    animation-timeline: --run;
    animation-range: contain 22% contain 46%;
  }
}

/* ---- latched ----
 * Set by js the first time the set finishes arriving, and never removed.
 *
 * The first version of this pinned the television visible and stopped there,
 * which broke the run: the montage and the title kept their reversible fades,
 * so scrolling back up showed the montage UNDERNEATH a television that was
 * locked on. Two states of the sequence at once.
 *
 * The thing that must not happen twice is the set ASSEMBLING — tube alone,
 * then cabinet materialising around it. That is the part that shows the
 * working. A finished television dissolving as one object shows nothing, and
 * has to stay reversible or the run cannot be scrolled backwards at all.
 *
 * So: the internal reveal is switched off, and the LAYER takes over the
 * cross-fade over the same span. Scrolling back up now dissolves a whole
 * television into the title and then the montage, one thing at a time.
 *
 * `animation: none` rather than an !important override, deliberately. Removing
 * the animation removes its fill, so each element falls back to its own
 * resting value — and the LED keeps the blink it uses for a channel change,
 * which an opacity override would have flattened for good.
 */
/* (The block that used to pin the parts at their resting values on the latch
   lived here. It kept the cabinet visible for good, which meant scrolling back
   up into the pull-back showed a full-size cabinet around a full-screen film.
   Everything is scroll-driven now, so there is nothing to pin.) */
/* NOTHING takes over at the latch any more, which is why there is no rule
   here. The layer used to inherit the cross-fade so a finished television
   could dissolve as one object; the set no longer assembles out of parts — it
   scales, and the cabinet fades, and both reverse on their own. The stale
   range left on that rule is what blacked the television out between contain
   36% and 64%, so if a fade is ever put back on this layer, check it against
   where `is-on` actually fires (~36%) before trusting it. */

/* pointer-events travels with opacity. A layer at zero opacity is still a
   layer: without this the montage's sound toggle stays clickable underneath
   the title and the television, which is the kind of thing nobody finds on
   purpose and everybody finds by accident. Discrete properties flip at the
   halfway point of the range, which is where the layer is half gone. */
@keyframes run-in {
  from { opacity: 0; pointer-events: none; }
  to   { opacity: 1; pointer-events: auto; }
}
@keyframes run-out {
  from { opacity: 1; pointer-events: auto; }
  to   { opacity: 0; pointer-events: none; }
}
@keyframes tv-pull {
  from { transform: scale(var(--pull, 2.2)); }
  to   { transform: scale(1); }
}
/* Reality -> a picture of reality, which is the interrogation shot run
   backwards. It opens on nothing at all: no filter, no transform, the film
   exactly as shot. What it lands on is a CRT — a touch hot, a touch soft, a
   touch contrastier than life, because that is what a tube does to everything
   it shows.

   The flex at 54-68% is the moment it stops being the world. The picture
   swells, over-saturates and loses focus for about a fifth of the range and
   then settles, which lands mid-way through the cabinet arriving — so the
   thing that breaks is also the thing that reveals the television. */
/* The film ALWAYS fills the tube, and there was an attempt here to make it not
   do that — a counter-scale that shrank it until it only just covered the
   window, so the opening would be less cropped. It cannot work: `.tv-screen`
   is the box the mask is cut to, and the bug, the scanlines and the glare are
   all sized to it, so a smaller video is a picture floating inside its own
   overlays. A fixed-shape aperture cannot show more film than it is shaped
   for. The set is scaled up past the curve instead. */
@keyframes tv-warp {
  0%   { filter: none; transform: none; }
  30%  { filter: saturate(1.05) contrast(1.02); transform: scaleY(1.004); }
  50%  { filter: saturate(1.26) contrast(1.11) brightness(1.07) blur(0.4px);
         transform: scale(1.012) scaleY(1.02); }
  62%  { filter: saturate(1.32) contrast(1.15) brightness(1.03) blur(0.55px);
         transform: scale(0.995) scaleY(0.986); }
  78%  { filter: saturate(1.21) contrast(1.11) brightness(1) blur(0.36px);
         transform: scale(1.003) scaleY(1.004); }
  100% { filter: saturate(1.16) contrast(1.09) brightness(0.98) blur(0.3px);
         transform: none; }
}
@keyframes run-set-in {
  /* Scale from just under 1 as well as fading: a set that only fades reads as
     a transparency, one that also settles reads as arriving. Small, because
     the picture inside it must not appear to drift while it happens. */
  from { opacity: 0; transform: scale(0.972); }
  to   { opacity: 1; transform: none; }
}

/* No scroll-driven animations, or motion declined: there is no sequence, so
   the run becomes what it is made of — a film, a title and a television, in
   order, each on its own. Without this the fills hold opacity 0 and the whole
   thing is three screens of black. */
@media (prefers-reduced-motion: reduce) {
  .run-scroller { background: none; height: auto; view-timeline-name: none; }
  .run-stage {
    position: static;
    height: auto;
    display: block;
    overflow: visible;
  }
  .run-layer {
    position: static;
    display: block;
    height: auto;
    padding: var(--sec-pad) 0;
    animation: none !important;
    opacity: 1 !important;
  }
  .run-layer > * { margin: 0 auto; }
  .run-layer.is-tv { pointer-events: auto; }
  .run-layer.is-tv .tv-cabinet { transform: none; }
  .run-layer .tv-screen,
  .run-layer .tv-frame,
  .run-layer .tv-led,
  .run-layer .tv-hit,
  .run-layer .sec-title {
    animation: none !important;
    opacity: 1 !important;
    transform: none !important;
  }
}
@supports not (animation-timeline: view()) {
  .run-scroller { background: none; height: auto; }
  .run-stage {
    position: static;
    height: auto;
    display: block;
    overflow: visible;
  }
  .run-layer { position: static; display: block; height: auto; padding: var(--sec-pad) 0; }
  .run-layer > * { margin: 0 auto; }
  .run-layer.is-tv { pointer-events: auto; }
  .run-layer.is-tv .tv-cabinet { transform: none; }
}

.station-card {
  margin: clamp(20px, 3.5vh, 40px) auto clamp(26px, 4.5vh, 52px);
  max-width: 1200px;
  padding: 0 var(--gut);
}
.station-card iframe {
  display: block;
  margin: 0 auto;
  border-radius: 12px;
}

/* A real <h2> the section needs for structure, kept out of sight because the
   card already says the name. Not display:none — that would take it away from
   screen readers too. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* The picture is a link now. The cursor is the whole affordance — a visible
   button over the film would fight the set. */
.tv-screen.is-link { cursor: pointer; }

/* ============================================================
   PASS 3 — ONE GROUND, ONE HEADER

   Two things were making the page read as a stack of separate slabs rather
   than one document.

   1. The ground hard-switched between --ground and --ground-deep section by
      section, so every boundary was a visible horizontal seam.
   2. There were two species of header: two full-bleed banners (GTV, WG8R) and
   eight centred lines of text floating in space, sharing nothing.

   Fixed by giving the page a single continuous ground, and every section the
   same header object — a full-bleed band. The banners were already that shape;
   now the text headers are too, so the two stop arguing.
   ============================================================ */

/* .sec-dark is kept as a hook but no longer paints a slab — the class is
   still on several sections and may want a subtler treatment later. */

/* ---- one header ----
   Every section title becomes the same band the banners already are: full
   bleed, hairline rules, a consistent darkening rather than a new colour — so
   it sits ON the continuous ground instead of interrupting it.

   The pile and wild titles are included now. They sit inside pinned stages, so
   they take their timeline from the section instead of themselves — see the
   overrides in v2.css. Only visually-hidden titles opt out, and those have
   nothing to show. */
.sec-title:not(.visually-hidden) {
  position: relative;
  margin-left: calc(var(--gut) * -1);
  margin-right: calc(var(--gut) * -1);
  margin-bottom: clamp(28px, 4.5vh, 56px);
  /* Tighter than it was: the rules sat a long way off the type. */
  padding: clamp(14px, 2.4vh, 30px) var(--gut);
  /* Opaque, and the same everywhere — see `--band-bg` on :root. This was a 28%
     tint, and the page's ground runs #1b1a1c..#28272a across its gradient, so
     one rule painted a different grey depending on where you had scrolled to.
     Solid also means content can travel underneath a pinned header without
     reading through it. */
  background: var(--band-bg);
  line-height: 1.06;
}
/* The rules are pseudo-elements rather than borders so they can sweep out from
   the centre. A border can only fade; a line that draws itself is the thing
   that makes the band feel switched on rather than revealed. */
.sec-title:not(.visually-hidden)::before,
.sec-title:not(.visually-hidden)::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  height: 1px;
  background: var(--primary);
  opacity: 0.55;
  transform: scaleX(0);
  transform-origin: 50% 50%;
}
.sec-title:not(.visually-hidden)::before { top: 0; }
.sec-title:not(.visually-hidden)::after { bottom: 0; }
/* A kicker directly under a band belongs to it — pull it up against the rule
   rather than leaving it stranded in the gap. */
.sec-title:not(.visually-hidden) + .sec-kicker {
  margin-top: calc(clamp(28px, 4.5vh, 56px) * -1 + 14px);
}

/* Sections that already strip their own gutters would double the negative
   margin and push the band off screen. */
/* (The foam hat's and the office's bands used to zero their negative margins
   here, because those sections have no gutter for the band to come out of. The
   plate carries that exception now — see `.sec-office .plate` in v2.css — so
   every band takes the same rule.) */

/* ---- the band arrives, and leaves ----
   One motion for every header on the page, which is the other half of what made
   them feel unrelated.

   Entry and exit are two animations, not one long one, because they run over
   different spans of the timeline. band-out uses `forwards` rather than `both`
   deliberately: `both` would fill BACKWARDS too, and since it is later in the
   list it would override the entry animation's opacity for the whole approach —
   the header would never appear at all. `forwards` applies nothing before its
   own range starts. */
@supports (animation-timeline: view()) {
  .sec-title:not(.visually-hidden) {
    animation: band-in linear both, band-out linear forwards;
    animation-timeline: view(), view();
    /* Measured against `cover`, whose span is the header's whole pass across
       the viewport, so these percentages mean the same thing at any header
       size. The build now finishes around mid-screen instead of a third of the
       way up — the old range was over before it registered. */
    animation-range: cover 0% cover 44%, cover 60% cover 84%;
  }
  /* The rules draw a beat behind the band, so it assembles rather than
     appearing all at once. */
  .sec-title:not(.visually-hidden)::before,
  .sec-title:not(.visually-hidden)::after {
    animation: rule-draw linear both;
    animation-timeline: view();
    animation-range: cover 10% cover 52%;
  }
  /* The words travel further and land later than the band around them. A band
     and its type moving as one rigid block reads as a slide; the lag is what
     makes it read as arriving. The exit needs no counterpart — the band's own
     fade takes its children with it. */
  .sec-title:not(.visually-hidden) .band-text {
    display: inline-block;
    animation: band-text-in linear both;
    animation-timeline: view();
    animation-range: cover 6% cover 50%;
  }
}

/* (A "typed headers" variant lived here, toggled with H. It typed the title
   itself, which the plate's prompt line above the band has superseded — and two
   things typing at once was never the design. It is gone, along with the mode
   that switched it on: that mode persisted in localStorage, so once pressed it
   stayed on across sessions and read as a bug that had appeared on its own.) */

@keyframes band-in {
  from {
    opacity: 0;
    /* Further than before, with a little scale: at 20px it faded up in place
       rather than arriving. */
    transform: translate3d(0, 54px, 0) scale(0.965);
    background-color: rgba(0, 0, 0, 0);
  }
  to {
    opacity: 1;
    transform: none;
    background-color: var(--band-bg);
  }
}
/* The departure. Only opacity and a short lift — the background is left to
   band-in's fill, which is harmless once opacity has reached zero. */
@keyframes band-out {
  from { opacity: 1; transform: none; }
  to   { opacity: 0; transform: translate3d(0, -30px, 0); }
}
/* The rules sweep out from the centre. A border can only fade; a line that
   draws itself is what makes the band read as switching on. */
@keyframes rule-draw {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}
@keyframes band-text-in {
  from {
    opacity: 0;
    transform: translate3d(0, 26px, 0);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .sec-title,
  .sec-title .band-text {
    animation: none !important;
    opacity: 1 !important;
    transform: none !important;
  }
  /* The rules animate from scaleX(0). Without an explicit resting state here
     they would never draw, and the band would lose its hairlines entirely. */
  .sec-title::before,
  .sec-title::after {
    animation: none !important;
    transform: scaleX(1) !important;
  }
}
