/* TOTALITY — site.css
   All styles for the site live in this one file. See
   TOTALITY_BUILD_SPEC.md §4 for the design system this implements. */

/* ---------- Font ---------- */

@font-face {
  font-family: "Jost";
  src:
    url("/assets/fonts/jost-variable-latin.woff2") format("woff2-variations"),
    url("/assets/fonts/jost-variable-latin.woff2") format("woff2");
  font-weight: 200 500;
  font-style: normal;
  font-display: swap;
}

/* V4_REVISIONS §2.5 — the real italic. The home page's endorsement
   quote is set in bold italic, and with only the upright face
   self-hosted the browser fakes it by shearing the upright
   letterforms. A synthesised italic has no true italic forms and no
   optical correction, and it shows immediately next to real type.
   Same source and licence as the upright (Jost is SIL OFL, latin
   subset, downloaded once and committed — no Google Fonts request at
   runtime, per §3 of the build spec), just the italic variable file.
   Declaring font-style: italic here is what makes the browser use it
   instead of synthesising; scripts/check-italic-font.js confirms the
   face that actually renders is this one. */
@font-face {
  font-family: "Jost";
  src:
    url("/assets/fonts/jost-variable-italic-latin.woff2") format("woff2-variations"),
    url("/assets/fonts/jost-variable-italic-latin.woff2") format("woff2");
  font-weight: 200 500;
  font-style: italic;
  font-display: swap;
}

/* ---------- Reset ---------- */

*, *::before, *::after { box-sizing: border-box; }

html, body, h1, h2, h3, h4, p, blockquote, figure, ul, ol {
  margin: 0;
  padding: 0;
}

ul, ol { list-style: none; }

img, video { max-width: 100%; display: block; }

a { color: inherit; text-decoration: none; }

button {
  font: inherit;
  color: inherit;
  background: none;
  border: none;
  cursor: pointer;
}

/* ---------- Tokens ---------- */

:root {
  /* Sampled from the hero frame itself — see TOTALITY_BUILD_SPEC.md
     §4.1. The page continues the photograph at its edges: sections
     below the hero start at exactly --ground-base (the frame's own
     bottom-edge value), and interior pages run a slow vertical
     gradient from --ground-rise through --ground-base to
     --ground-deep, so the whole site reads as one continuous
     twilight rather than a hero photo dropped onto an unrelated flat
     colour. */
  --ground-sky:    #232C41;
  --ground-rise:   #151A26;
  --ground-base:   #171519;
  --ground-deep:   #12141A;

  --horizon:       #C08A50;  /* primary accent — text/links/CTAs. AA at every ground. */
  --horizon-raw:   #9C754D;  /* literal measured amber — large text/decoration ONLY, fails AA small */
  --horizon-olive: #A8A48F;  /* secondary accent, desaturated */
  --horizon-lo:    #7A5330;  /* pressed/active FILL only — never as a text/foreground colour, fails AA (§4.1) */
  /* V4_REVISIONS §2.3 — the hero CTA's hover/focus colour. The button
     no longer fills on hover, so the state change has to be carried by
     the border and label brightening instead. Measured against the
     crowd band the CTA sits on: --horizon reads ~5.2:1 there and this
     ~7.9:1, so the hover state is strictly more legible, not merely
     different. */
  --horizon-hi:    #E3B27C;

  --corona:     #F2EFE6;
  --corona-dim: rgba(242, 239, 230, 0.62);
  --rule:       rgba(242, 239, 230, 0.14);

  --gutter:  clamp(1.5rem, 6vw, 5rem);
  /* V1_REVISIONS §0.2: was clamp(4rem, 9vw, 8rem) (128px at desktop).
     Applied via padding-block on every `main > section`, it stacked at
     every boundary — a section's own bottom plus the next section's
     top — so a plain title-to-content gap was never just one rhythm
     value; a between-sections gap was two. ~44% less at the 8rem cap
     (128px -> 72px), landing the previously-doubled sibling-section gap
     at 144px (was 256px) and the page-title gap at 104px (was 160px) —
     both "roughly 40% less," per spec, without restructuring which
     elements carry the padding. */
  --rhythm:  clamp(2.5rem, 5vw, 4.5rem);
  --content-max: 1200px;
  --prose-max: 62ch;

  --motion-fast: 160ms ease;
  --motion-expander: 240ms ease;

  /* The horizon rule, as a reusable value: originally one structural
     use on .site-nav, now also reused on lifted surfaces (the host
     form, §4.1) and modals (§4.4/§5.2) as their own top edge — same
     amber band each time, defined once so the three stay in sync. */
  --horizon-gradient: linear-gradient(
    90deg,
    rgba(18, 20, 26, 0) 0%,
    #6F6C60 18%,
    #9C754D 42%,
    #C08A50 58%,
    #6F6C60 80%,
    rgba(18, 20, 26, 0) 100%
  );

  /* Width the hero title occupies, text or image. Derived in V3 from
     the text wordmark's measured width-per-font-size factor (4.847)
     run through the title's own 11vw clamp, so the artwork lands on
     exactly the width the text used to have. Consumed both by the
     rendered <img> and by the pre-swap placeholder box on
     .hero__title:not(.is-ready). */
  --hero-title-w: clamp(387.76px, 53.32vw, 697.97px);
  /* Distance from the top of the hero to the top of the title cluster,
     which with introLine null is the top of the title itself — and
     with an intro line is still the edge the eclipse has to clear, so
     it is the right thing to measure against either way.

     THE ONE PLACE this distance is stated. .hero__content turns it
     into its own padding-top and the eclipse layer clamps itself
     against it, so the two can never drift. It is declared on :root,
     NOT on .hero__content, for exactly that reason: a copy living on
     .hero__content is invisible to .hero__eclipse, which is a sibling
     subtree, and the eclipse then clamps against a number that is not
     where the title is. The short-viewport regime redefines it — on
     :root again — and the two offsets are not a duplicated constant
     but two measured regimes: the offset is half the cluster's height
     in each, and the 38% midpoint target is shared.

     V5 follow-up: both offsets carry a further +12px, which moves the
     cluster DOWN by exactly that at every viewport. On the poster the
     eclipse clears the title by 1.63 title-heights (2.98 disc
     diameters); the first V5 web build held 0.80 (1.49), so the two
     read as one crowded mass instead of two elements. 12px on the
     cluster plus 1.2% on the desktop eclipse's y restores the
     proportion. The 12px is identical in both branches deliberately —
     the regimes differ in where the cluster sits, not in how far it
     needed to move.

     This token is load-bearing for the eclipse, not just for the
     text: it feeds --hero-eclipse-band, so moving it down also grows
     the band by 12px and the strip gets larger wherever it is at its
     cap. That is intended and it buys legibility near the height
     threshold, but it is why this was re-measured at every size
     rather than treated as a text nudge.

     Measured against the live page, the title's top is 0.38*vh - 138
     for viewports taller than 1150px and 0.38*vh - 91 at or below
     it, at every width in both layout regimes; it is a function of
     viewport HEIGHT only and does not move with width. */
  --hero-cluster-top: clamp(2rem, calc(38vh - 138px), 32rem);
  /* The other edge of the band the eclipse has to live in. The nav is
     fixed to the top and transparent over the hero, and its height is
     content-driven: measured 75.39px wherever the nav items show and
     77.39px below 820px where they collapse to the Menu toggle. These
     are the next rem step above each, so the clearances computed from
     them are never optimistic. Re-measure if the nav's padding, font
     size or 820px breakpoint change. */
  --hero-nav-bottom: 4.75rem;
  /* The title's unlit state — V4_REVISIONS §0.1 asks for roughly 20%
     of resting brightness, and this is now the state the title is
     first revealed in rather than a phase it passes through. Tuned
     against measured luminance, not alpha: --corona composited at
     alpha a has relative luminance ~0.86 at a=1, and 20% of that
     (0.173) lands at a=0.48, where V3's 0.32 measures 8.6%. Dark
     enough to read as unlit, light enough that the word is plainly
     there. */
  --hero-sweep-dim: rgba(242, 239, 230, 0.48);
}

/* ---------- Base ---------- */

html {
  background: var(--ground-base);
  color-scheme: dark;
}

body {
  /* Interior-page default: a fixed-to-viewport gradient, so any page
     without its own hero photo reads as sky-at-top / deep-at-foot
     regardless of scroll position or document length. Pages with a
     hero (home) simply paint over this in the hero's own box — see
     .hero and .trailer, which set explicit backgrounds. */
  background: linear-gradient(to bottom, var(--ground-rise) 0%, var(--ground-base) 55%, var(--ground-deep) 100%);
  background-repeat: no-repeat;
  background-attachment: fixed;
  background-size: 100% 100vh;
  color: var(--corona);
  font-family: "Jost", sans-serif;
  font-weight: 300;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}

/* Film grain: a subtle SVG feTurbulence texture over the whole
   viewport, fixed, non-interactive. Gives the flat grounds depth to
   match the photographic source. No stars, no particles — the frame
   itself contains none, and inventing them would create a visible
   seam against the real sky in the hero photo. */
body::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 5;
  pointer-events: none;
  opacity: 0.03;
  background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScyMDAnIGhlaWdodD0nMjAwJz48ZmlsdGVyIGlkPSduJz48ZmVUdXJidWxlbmNlIHR5cGU9J2ZyYWN0YWxOb2lzZScgYmFzZUZyZXF1ZW5jeT0nMC44NScgbnVtT2N0YXZlcz0nMycgc3RpdGNoVGlsZXM9J3N0aXRjaCcvPjxmZUNvbG9yTWF0cml4IHR5cGU9J3NhdHVyYXRlJyB2YWx1ZXM9JzAnLz48L2ZpbHRlcj48cmVjdCB3aWR0aD0nMTAwJScgaGVpZ2h0PScxMDAlJyBmaWx0ZXI9J3VybCgjbiknLz48L3N2Zz4=");
  background-repeat: repeat;
}

:focus-visible {
  outline: 2px solid var(--horizon);
  outline-offset: 3px;
}

.is-hidden {
  display: none;
}

/* ---------- Layout primitives ---------- */

.container {
  max-width: var(--content-max);
  margin-inline: auto;
  padding-inline: var(--gutter);
}

.prose {
  max-width: var(--prose-max);
}

main > section + section,
main > section {
  padding-block: var(--rhythm);
}

hr, .rule {
  border: none;
  border-top: 1px solid var(--rule);
}

/* ---------- Type scale ---------- */

.text-page-title {
  font-size: clamp(2rem, 4vw, 3rem);
  font-weight: 200;
  letter-spacing: 0.08em;
}

.text-section-heading {
  font-size: 1.1rem;
  font-weight: 400;
  letter-spacing: 0.02em;
}

.text-screening-date {
  font-size: 1.5rem;
  font-weight: 300;
  letter-spacing: 0.04em;
}

.text-body {
  font-size: 1.05rem;
  font-weight: 300;
  line-height: 1.75;
}

.text-small {
  font-size: 0.85rem;
  font-weight: 300;
  color: var(--corona-dim);
}

/* ---------- Buttons and links ---------- */

.btn {
  display: inline-block;
  padding: 0.85em 1.75em;
  font-size: 0.9rem;
  font-weight: 400;
  letter-spacing: 0.06em;
  border-radius: 2px;
  border: 1px solid var(--horizon);
  background: transparent;
  color: var(--horizon);
  transition: background-color var(--motion-fast), color var(--motion-fast);
}

.btn:hover,
.btn:focus-visible {
  background: var(--horizon);
  color: var(--ground-deep);
}

.btn:active {
  /* --horizon-lo fails AA as a fill paired with dark text (2.7:1 —
     see §4.1's contrast table): keep the text --corona instead of
     inverting to a dark ground colour. corona-on-horizon-lo is 5.9:1. */
  background: var(--horizon-lo);
  border-color: var(--horizon-lo);
  color: var(--corona);
}

.btn--secondary {
  border-color: var(--corona-dim);
  color: var(--corona-dim);
}

.btn--secondary:hover,
.btn--secondary:focus-visible {
  background: var(--corona-dim);
  color: var(--ground-deep);
}

.btn--secondary:active {
  background: var(--corona);
  border-color: var(--corona);
  color: var(--ground-deep);
}

.link {
  color: var(--horizon);
  text-underline-offset: 0.25em;
  text-decoration-thickness: 1px;
  text-decoration-line: underline;
  transition: color var(--motion-fast);
}

.link:hover,
.link:focus-visible {
  color: var(--corona);
}

/* ---------- Navigation ---------- */

.site-nav {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  /* V4_REVISIONS §1: left-aligned, replacing V3's centring. Below
     820px this is the Menu toggle's alignment too, so the toggle sits
     where the items it stands in for would start. */
  justify-content: flex-start;
  padding: 1.5rem var(--gutter);
  background: transparent;
  border-bottom: 1px solid transparent;
  transition: background-color var(--motion-fast), border-color var(--motion-fast);
}

.site-nav.is-solid {
  background: var(--ground-rise);
  border-bottom-color: var(--rule);
}

/* The horizon rule: one structural use of the horizon gradient,
   mirroring the real amber band. Always present, on every page. */
.site-nav::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 1px;
  pointer-events: none;
  background: var(--horizon-gradient);
}

.site-nav__list {
  display: flex;
  align-items: center;
  gap: clamp(1.75rem, 3vw, 3rem);
}

.site-nav__link {
  /* V3_REVISIONS §1.3: was 0.9rem (14.4px), which the directors called
     too small. 1.1rem is 17.6px — a 22% increase.
     V4_REVISIONS §1: the labels are now capitals, typed into the HTML
     rather than applied with text-transform, consistent with the rest
     of the site. Capitals lose the ascender/descender profile that
     makes a lowercase word identifiable at a glance, so two things
     compensate: tracking goes 0.06em -> 0.14em, which is what stops
     short all-caps words collapsing into a block, and weight goes
     400 -> 500 (the top of the self-hosted Jost variable range), which
     restores the stroke weight capitals lose at this size. Measured at
     the 821px breakpoint the four items, their gaps and the gutter
     come to ~556px against 821px of bar, so the wider tracking costs
     nothing even at the narrowest desktop width. */
  font-size: 1.1rem;
  font-weight: 500;
  letter-spacing: 0.14em;
  color: var(--corona-dim);
  transition: color var(--motion-fast);
  /* V3_REVISIONS §1.4. Coloured nav links were considered and
     rejected; this is the legibility fix that replaces them. The nav
     sits over moving video on Home and over photographs on every
     interior page, so the type has no fixed background to be
     contrasted against. Two blurs, no offset: a tight one to hold the
     letterforms' own edges and a wider one to sink the whole item
     into whatever is behind it. Built on --ground-deep (18,20,26), so
     it reads as the page's own shadow rather than a grey halo. No
     visible offset, per §1.4 — an offset would read as a drop shadow
     and the site takes none anywhere else (§4.3). */
  text-shadow: 0 0 4px rgba(18, 20, 26, 0.6), 0 0 12px rgba(18, 20, 26, 0.4);
}

.site-nav__link:hover,
.site-nav__link:focus-visible {
  /* Solid colour transition only — no gradient on nav text (§4.1):
     background-clip on letterspaced 14px type goes muddy and its
     contrast can't be certified. */
  color: var(--horizon);
}

.site-nav__link[aria-current="page"] {
  color: var(--corona);
}

.site-nav__link.is-hidden {
  display: none;
}

.site-nav__toggle {
  display: none;
  color: var(--corona);
  /* Tracks .site-nav__link's size and shadow — it stands in for the
     whole item list below 820px and sits over the same hero video. */
  font-size: 1.1rem;
  letter-spacing: 0.06em;
  text-shadow: 0 0 4px rgba(18, 20, 26, 0.6), 0 0 12px rgba(18, 20, 26, 0.4);
}

.site-nav__overlay {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 99;
  background: var(--ground-rise);
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2rem;
}

.site-nav__overlay.is-open {
  display: flex;
}

.site-nav__overlay .site-nav__link {
  font-size: 1.5rem;
}

@media (max-width: 820px) {
  .site-nav__list {
    display: none;
  }

  .site-nav__toggle {
    display: block;
  }
}

/* ---------- Footer ---------- */

.site-footer {
  padding: var(--rhythm) var(--gutter) 3rem;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2rem;
  text-align: center;
}

/* V4_REVISIONS §2.6 — on the home page the footer is the lower half of
   a four-row group that has to read as one thing. Its own top padding
   goes entirely (the group's gap above it does that job now) and the
   row gap tightens to match .hero__bottom's. Keeping the interior
   generous here would reintroduce exactly the "four separate items"
   reading the directors objected to. */
.hero__bottom .site-footer--overlay {
  position: static;
  padding-top: 0;
  padding-bottom: 2.25rem;
  gap: 1.1rem;
}

.site-footer--solid {
  /* Foot of the page — --ground-deep, the darkest point (§4.1). */
  background: var(--ground-deep);
  border-top: 1px solid var(--rule);
}

.site-footer--overlay {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  /* V1_REVISIONS §0.3: needs to stack above .hero__content (z-index:2)
     — that div spans the full hero via inset:0, and while empty/
     transparent through most of its box, an empty box still captures
     clicks ahead of whatever sits at a lower z-index beneath it.
     Confirmed via elementFromPoint() before this fix: a click on the
     footer's "Stay in touch" link hit DIV.hero__content, not the
     anchor — the link was never receiving the click at all. */
  z-index: 3;
  background: linear-gradient(to top, rgba(18, 20, 26, 0.92) 0%, rgba(18, 20, 26, 0) 100%);
  padding-top: 6rem;

  /* V3_REVISIONS, flag 1 from the Phase 1 report. initFooter() injects
     the logo row, the copyright line and the hero video credit; all
     three are display:none or empty in the static markup, so the
     footer measured 196px at parse and 320px once they landed —
     124px of growth. Because this variant is bottom-anchored, that
     growth travels upward: the subnav jumps 124px up the hero. Small
     by the CLS number (0.0306) and invisible at low latency, where
     initFooter beats first paint — but on a slow load it is the one
     moving thing on a page that is deliberately a single viewport.
     Reserving each row's height in CSS is not possible here: the rows
     are config-driven, so an empty config would reserve space for
     content that never arrives and shift by the same amount in the
     other direction. Holding the whole footer until initFooter has
     resolved it is height-correct at every level of config
     completeness, and costs nothing in layout because nothing on the
     page depends on this element's box. Same mechanism as the hero
     title (§0.1), including the <noscript> release in index.html. */
  visibility: hidden;
}

.site-footer--overlay.is-ready {
  visibility: visible;
}

.site-footer__subnav {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 1.5rem;
}

.site-footer__subnav a {
  /* V3_REVISIONS §2.8: 0.85rem -> 1rem across the footer's text. */
  font-size: 1rem;
  color: var(--corona-dim);
  transition: color var(--motion-fast);
}

.site-footer__subnav a:hover,
.site-footer__subnav a:focus-visible {
  color: var(--corona);
}

/* V5: the icon group now lives INSIDE .site-footer__subnav rather than on
   a row of its own — five centred footer rows read as a ladder, and this
   one cost 48px of height for two 24px glyphs. As a flex item of the
   subnav it inherits that row's wrapping and centring. The small left
   margin is what separates "our other places" from the text links beside
   it; the tighter internal gap (1rem against the subnav's 1.5rem) is what
   keeps the two icons reading as a pair rather than as two more links. */
.site-footer__social {
  display: inline-flex;
  align-items: center;
  gap: 1rem;
  margin-left: 0.25rem;
}

.site-footer__social.is-empty {
  display: none;
}

.site-footer__social a {
  color: var(--corona-dim);
  transition: color var(--motion-fast);
}

.site-footer__social a.is-hidden {
  display: none;
}

.site-footer__social a:hover,
.site-footer__social a:focus-visible {
  color: var(--horizon);
}

.site-footer__social svg {
  /* V3_REVISIONS §2.8: 20px -> 24px, tracking the text around them. */
  width: 24px;
  height: 24px;
}

.site-footer__logos {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 2.5rem;
  flex-wrap: wrap;
}

.site-footer__logos.is-empty {
  display: none;
}

.site-footer__logos img {
  /* V4_REVISIONS §2.7: 113px -> 147px, driven from
     config.footer.logoWidth through --footer-logo-width so the About
     page's copy (§6.3) and this one can never drift apart, and so the
     next size change is a config edit rather than a CSS one.
     113px was the crisp ceiling for the current 227x38 source on a 2x
     display — measured, with the ink filling the file edge to edge and
     no padding (the "300x112" in its filename is wrong) — so 147px is
     knowingly a little soft until the higher-resolution file arrives
     from Sandbox. That will be a URL change in config with no sizing
     to revisit.
     An explicit `width`, not `max-width`: with `width: auto` an image
     that has not decoded yet has no width for aspect-ratio to work
     from, so it reserves nothing and the bottom group grows when the
     file lands — measured at 0.0042 of cold-load shift, moving the CTA
     with it. A definite width plus the ratio below reserves the box
     from first layout. */
  width: var(--footer-logo-width, 147px);
  max-width: 100%;
  max-height: none;
  height: auto;
  /* `auto` first, so a replacement file's own ratio wins on decode;
     227/38 is the current source's, standing in only until then. */
  aspect-ratio: auto 227 / 38;
}

.site-footer__logos a.is-hidden {
  display: none;
}

.site-footer__copyright {
  /* V3_REVISIONS §2.8: 0.8rem -> 0.95rem. */
  font-size: 0.95rem;
  color: var(--corona-dim);
}

/* Home page only — the hero video's credit. Deliberately smaller and
   dimmer than .site-footer__copyright so it reads as a subordinate
   line "near the copyright," not a second headline competing with the
   title/tagline/CTAs above it. Hidden by default in the markup and
   toggled by initFooter() in site.js, same is-hidden convention as
   every other config-driven element. */
.site-footer__video-credit {
  margin-top: 0.4rem;
  /* V3_REVISIONS §2.8: 0.7rem -> 0.85rem, holding its step below the
     copyright line so it still reads as subordinate to it. */
  font-size: 0.85rem;
  color: var(--corona-dim);
  opacity: 0.75;
}

/* ---------- Hero ---------- */

.hero {
  position: relative;
  height: 100svh;
  min-height: 100svh;
  overflow: hidden;
}

.hero__media {
  position: absolute;
  inset: 0;
  z-index: 0;
}

.hero__media img,
.hero__media video {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.hero__scrim {
  /* Lighter than an earlier draft of this file: the real hero frame
     measures dark at both the top (~12:1+ contrast against corona
     text with zero scrim) and the bottom (~15:1+), and only dips
     toward its weakest margin — still a passing 5:1 — in the amber
     glow band around the frame's vertical center. This scrim is a
     safety margin against a future, brighter replacement frame, not
     a correction to this one — see TOTALITY_BUILD_SPEC.md §6.1.1. */
  position: absolute;
  inset: 0;
  z-index: 1;
  background: linear-gradient(
    to bottom,
    rgba(18, 20, 26, 0.30) 0%,
    rgba(18, 20, 26, 0) 45%,
    rgba(18, 20, 26, 0.55) 100%
  );
}

/* The eclipse layer (V3_REVISIONS §2.2, artwork replaced in V5).
   Sits above the scrim and below the content, so it reads as part of
   the sky rather than something painted on top of the title. Every
   number comes from config.hero.eclipse via the custom properties
   site.js sets here; nothing about the placement is decided in this
   file, so the directors can dial it without a code change.
   --hero-eclipse-x/y address the layer's centre, which is what
   "50% across, 17.5% down" means to someone describing a composition.
   Deliberately not covered by the band aspect guard: the artwork is a
   wide transparent strip by design, so a 16:9 assertion would fail it
   correctly and pointlessly, exactly as it would the title treatment.
   Its real aspect arrives as --hero-eclipse-ar instead, straight from
   config.hero.eclipse.ratio, and that number has to keep matching the
   file: re-measure it whenever the artwork is replaced, or the layer
   renders at a box the asset does not fill. */
.hero__eclipse {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  overflow: hidden;
}

/* site.js wraps the <img> in a <picture> so the browser picks the
   desktop or mobile file itself. `display: contents` dissolves that
   wrapper: the <source> still does its job (file selection happens in
   picture's content model, not in layout) and the absolutely
   positioned <img> keeps .hero__eclipse as its containing block,
   without a stray empty inline box in between. Every rule below is a
   descendant selector, not a child one, so it reaches through it. */
.hero__eclipse picture {
  display: contents;
}

.hero__eclipse img {
  position: absolute;
  left: var(--hero-eclipse-x);

  /* THE BAND. The layer has to live between the bottom of the nav and
     the top of the title cluster, and both of those edges are known
     numbers, so neither the size nor the position has to be
     hand-tuned per breakpoint — the rule derives them.

     Why this replaces a viewport-height cap. The layer's width is a
     share of hero WIDTH and its position a share of hero height,
     which is fine until a viewport is wide and short: the band is a
     function of height alone (the title's top is 0.38*vh - 91px in
     the short regime and the nav is a fixed bar), while the layer
     grows with width, so on a short wide frame the layer outgrows the
     gap it is supposed to sit in. The old square eclipse hit this at
     1366x768, where 22% of width was 300px of eclipse dropped into a
     113px band and the corona landed on the wordmark (measured — peak
     alpha 68 inside the overlap). A cap written as a share of
     viewport height is only a proxy for the band and gets it wrong
     wherever the two diverge, so the band itself is computed instead.

     16px of clearance at each end, hence the 32px. max(0px, ...) is
     defensive: on a pathological viewport the band can go to zero,
     and a negative one would make the clamp ill-formed.

     --hero-eclipse-d is a WIDTH, so the band — a HEIGHT — has to be
     multiplied back through the aspect before the two can be
     compared. This is the whole reason the ratio is plumbed through
     from config. The rule this replaced read `min(size, 30svh)`,
     capping the WIDTH against a height, which is the same thing only
     while width == height; on an 8:1 strip at 1080px tall it would
     have clamped a 1229px-wide strip to 324px.

     The centre is then clamped so that neither end of the layer can
     cross into its 16px margins. Note that the upper bound subtracts
     the layer's own half-height: bounding the CENTRE against the
     title, as the rule here used to, still lets the lower half lie on
     it. That was survivable for a small square and is not for a
     strip. site.js hands size and y over in vw/svh rather than
     percentages so they can be compared here; see its comment. */
  --hero-eclipse-band: max(0px, calc(var(--hero-cluster-top) - var(--hero-nav-bottom)));
  --hero-eclipse-maxh: max(0px, calc(var(--hero-eclipse-band) - 32px));
  --hero-eclipse-d: min(
    var(--hero-eclipse-size),
    calc(var(--hero-eclipse-ar, 1) * var(--hero-eclipse-maxh))
  );
  --hero-eclipse-half: calc(var(--hero-eclipse-d) / var(--hero-eclipse-ar, 1) / 2);
  top: clamp(
    calc(var(--hero-nav-bottom) + 16px + var(--hero-eclipse-half)),
    var(--hero-eclipse-y),
    max(
      calc(var(--hero-nav-bottom) + 16px + var(--hero-eclipse-half)),
      calc(var(--hero-cluster-top) - 16px - var(--hero-eclipse-half))
    )
  );
  width: var(--hero-eclipse-d);
  height: auto;
  /* The asset's intrinsic aspect, handed over by site.js from
     config.hero.eclipse.ratio, so the box is known without waiting for
     the decode and the layer never reflows. The fallback of 1 is the
     old square behaviour, for a config written before the key existed. */
  aspect-ratio: var(--hero-eclipse-ar, 1);
  transform: translate(-50%, -50%);
  /* Full strength, deliberately. The asset carries the poster's own
     alpha curve already baked in — the poster composites through a
     path that raises partial alpha to roughly its cube, and the corona
     is almost entirely partial alpha — so the file reproduces the
     approved corona exactly. The 0.85 this used to carry would render
     the web corona dimmer than the approved print one. */
  opacity: 1;
}

/* V3_REVISIONS §2.7 — the laurel row and festival quote.
   The wrapper is exactly as wide as the title so the accolades can be
   measured against it ("width roughly half the width of the rendered
/* One laurel takes half the title's width, per §2.7. A second one
   arriving in November shouldn't halve them both to fit that same
   box, so the row is allowed the title's full width once there are
   two — still bounded by the gutter term above, which is what
   actually constrains it on a narrower screen. :has() failing simply
   leaves both laurels sharing the one-laurel width, which is legible
/* V4_REVISIONS §2.3/§2.6 — the bottom group: CTA, Sandbox logo,
   footer links, copyright. Bottom-anchored as one block so the four
   rows can be tightened together, and so the CTA's top edge can be
   held below the contrast line §2.3 sets (see .hero__ctas). */
.hero__bottom {
  /* Held until initFooter has finished, for the same reason the footer
     itself was in V3 — except the gate has to be here now. The group
     is bottom-anchored, so every row initFooter adds pushes the CTA
     above it upward: measured, the group grew 156px -> 197px -> 221px
     on a cold load and took the CTA with it. Hiding the footer alone
     no longer helps, because a hidden element still occupies its box
     and the box is what was changing. site.js marks this element in
     the same finally that marks the footer, so a throw in there still
     reveals the group rather than leaving the hero without one. */
  visibility: hidden;
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 3;
  display: flex;
  flex-direction: column;
  align-items: center;
  /* The single gap that makes the four rows read as one group rather
     than four separate items. */
  gap: 1.1rem;
}

/* V4_REVISIONS §2.4 — the endorsement, lower right on the crowd.
   The block is exactly as wide as the quote's longer line (max-content
   against a pre-line quote), which is what lets the laurel be "half
   the width of the quote" and right-aligned to it without either
   measurement being hardcoded anywhere. */
.hero__bottom.is-ready {
  visibility: visible;
}

.hero__endorsement {
  position: absolute;
  z-index: 3;
  right: var(--gutter);
  /* Clear of the 68% line that §2.3 establishes — the whole block,
     quote included, has to sit on the crowd. Held in rem rather than a
     percentage because the block's own height barely changes across
     desktop sizes (the quote is a fixed size), so a fixed offset from
     the foot keeps the top edge below the line at 768px of height as
     well as at 900. Measured: the top lands at 73.2% at 1440x900 and
     68.6% at 1366x768. */
  bottom: 3.5rem;
  width: max-content;
  max-width: min(38ch, 34vw);
}

.hero__endorsement.is-empty {
  display: none;
}

.hero__endorsement__quote {
  /* V4_REVISIONS §2.4: 20% larger than V3's 0.95rem quote, bold
     italic, and with no attribution — the laurel beneath it is the
     attribution. pre-line preserves the one deliberate line break the
     quote is set with, so the break lives in the content rather than
     in a <br> the CSS has to guess at. */
  margin: 0;
  white-space: pre-line;
  /* V6: ragged left, flush right — was centred, before there was
     anything opposite it to agree with. Now this block and the press
     block each align to the OUTER edge they sit against, so the quote
     and the laurel beneath it share one axis instead of the text
     floating centred above a mark pinned to the edge. Measured at
     1600x900: centred, these lines ended 9 and 30px short of the
     laurel's right edge; flush, both meet it. The rag falls inward,
     toward the open middle of the frame, which is where it should
     fall. Returns to centred on phones — see the breakpoint block. */
  text-align: right;
  font-size: 1.14rem;
  font-weight: 500;
  font-style: italic;
  line-height: 1.45;
  color: var(--corona);
  /* Same reasoning as the nav and the tagline: this sits over moving
     video with bright points in it. */
  text-shadow: 0 0 10px rgba(18, 20, 26, 0.9), 0 0 22px rgba(18, 20, 26, 0.7);
}

.hero__endorsement__quote::before { content: "\201C"; }
.hero__endorsement__quote::after  { content: "\201D"; }

.hero__endorsement__laurel {
  /* Half the width of the quote block — which is the quote's longer
     line — and pushed to its right edge, the way a signature sits
     under the end of a quotation.
     `contain: inline-size` is load-bearing and not a nicety. The
     parent is sized by max-content so that it measures exactly the
     quote's longest line; without containment the laurel's own
     intrinsic width (1776px from the trimmed crop) is what
     max-content picks up instead, the parent inflates to its
     max-width, and 50% of that is no longer 50% of the quote —
     measured, the laurel rendered at 73.4% of the longest line rather
     than 50%. Containment makes the laurel contribute nothing to its
     parent's inline size, so the quote alone defines the block and
     the percentage means what it says.
     Where `contain` is unsupported the laurel is merely larger than
     intended, never broken or overflowing: max-width still bounds it. */
  contain: inline-size;
  width: 50%;
  max-width: 50%;
  margin-left: auto;
  margin-top: 0.7rem;
}

.hero__endorsement__laurel img {
  display: block;
  width: 100%;
  height: auto;
  /* The trimmed crop is 1776x1588; this only governs the frames
     before it decodes. V7: was 1666x1588, for the Official
     Competition mark. The award laurel that replaced it is 110px
     wider for its longer bottom line, same height — so at the 50%
     width below it renders about 7px shorter than the old one. */
  aspect-ratio: auto 1776 / 1588;
  /* White at reduced opacity for the soft grey, as in V3, with the
     same drop-shadow so it survives the lit parts of the crowd. */
  opacity: 0.68;
  filter: drop-shadow(0 0 10px rgba(18, 20, 26, 0.75));
}

/* V6 — the press quote, lower left. A mirror of the endorsement
   above rather than a copy: same placement logic, same type, but
   with an attribution line the endorsement doesn't need and without
   the automatic quotation marks the endorsement does. Read the
   endorsement's comments first — this block only explains where the
   two differ. */
.hero__press {
  position: absolute;
  z-index: 3;
  /* The mirror of .hero__endorsement's `right`, on the same `bottom`,
     so the pair sits on one baseline and reads as a pair. */
  left: var(--gutter);
  bottom: 3.5rem;
  width: max-content;
  /* 28vw, where the endorsement opposite uses 34vw. The difference is
     not cosmetic and it is not an oversight. The endorsement's quote
     is short enough that `width: max-content` settles under its cap at
     every size, so that cap never actually binds; this quote is long
     enough to hit its cap always, which makes the cap the thing that
     decides the block's width. At 34vw the block reaches past the
     centred trailer button on the narrowest desktop widths — measured
     at 901px, the last width before the phone stack takes over, the
     third line overlapped the button by 36px. 28vw clears it by 18px
     and is inert above about 1220px, where the 38ch term takes over
     and the layout is identical to what was approved. If you widen
     this, re-check 901x900 first. */
  max-width: min(38ch, 28vw);
}
.hero__press.is-empty {
  display: none;
}
.hero__press__quote {
  /* Deliberately identical to .hero__endorsement__quote — same size,
     weight, italic, alignment logic and shadow. The two only balance
     if they are the same voice. */
  margin: 0;
  white-space: pre-line;
  /* Flush left, the mirror of the endorsement's flush right: each
     block aligns to the outer edge it sits against. Here that puts the
     quote, the attribution below it and the laurel below that all on
     one axis — x=80 at 1600x900, which is also the nav's left edge.
     Centred they sat on four different axes.
     One thing to know if you edit the quote: it currently begins with
     the word "Portrays", so the left edge is true. Start the string
     with a quotation mark instead and that first line will look
     indented by the width of the mark, because browsers other than
     Safari do not hang punctuation outside the measure. */
  text-align: left;
  font-size: 1.14rem;
  font-weight: 500;
  font-style: italic;
  line-height: 1.45;
  color: var(--corona);
  text-shadow: 0 0 10px rgba(18, 20, 26, 0.9), 0 0 22px rgba(18, 20, 26, 0.7);
}
/* The one difference, and it is an absence: no ::before/::after here.
   The endorsement wraps its whole quote in marks because the whole
   quote is the festival's wording. On this block the first word is
   ours and the rest is the publication's, so the marks have to sit
   inside the string, around the part that is actually quoted — they
   are typed into config.hero.press.quote. Adding them here as well
   would print them twice, which is exactly what the first mock of
   this block did. */

.hero__press__source {
  /* The signature that the endorsement gets from its laurel. Caps at
     the quote's own colour, letterspaced so one short word reads as a
     mark rather than as a stray line of text. */
  margin: 0.5rem 0 0;
  /* Left with the quote above it and the laurel below it — the three
     share one edge. */
  text-align: left;
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--corona);
  text-shadow: 0 0 10px rgba(18, 20, 26, 0.9), 0 0 22px rgba(18, 20, 26, 0.7);
}
/* The link is on the attribution alone. Underline on hover only: a
   permanently underlined word would read as navigation sitting in the
   middle of the artwork. */
.hero__press__source a {
  color: inherit;
  text-decoration: none;
  border-bottom: 1px solid transparent;
  transition: border-color 0.2s ease;
}
.hero__press__source a:hover,
.hero__press__source a:focus-visible {
  border-bottom-color: currentColor;
}

.hero__press__laurel {
  /* `contain: inline-size` for exactly the reason the endorsement's
     laurel needs it: without it the image's intrinsic 1220px is what
     the max-content parent measures, the block inflates to its
     max-width, and the percentage below stops meaning what it says.
     The endorsement's laurel carries the full explanation. */
  contain: inline-size;
  /* 28%, where the endorsement's laurel is 50%. Not an inconsistency —
     the two marks are matched by WEIGHT, not by measurement. The FNC
     mark is open line art; this one is a solid wreath, so at equal
     size it reads much heavier and drags the composition left. 28%
     puts it at roughly 96x82px against FNC's 125x119, which is where
     the two sit level. If the artwork is ever swapped, re-judge this
     by eye rather than copying 50% across from above. */
  width: 28%;
  max-width: 28%;
  /* Mirrors the endorsement's `margin-left: auto`: that laurel meets
     the right edge of its block, this one the left edge. */
  margin-right: auto;
  margin-left: 0;
  margin-top: 1.15rem;
}
.hero__press__laurel img {
  display: block;
  width: 100%;
  height: auto;
  /* Governs the frames before the image decodes; 1220x1046 is the
     trimmed crop the config URL asks Uploadcare for. */
  aspect-ratio: auto 1220 / 1046;
  /* Same treatment as the endorsement's laurel — white artwork dimmed
     to the soft grey, with a shadow so it survives the lit parts of
     the crowd. This is why config must point at the WHITE file: the
     black one dimmed to 68% measures 1.4:1 against this sky. */
  opacity: 0.68;
  filter: drop-shadow(0 0 10px rgba(18, 20, 26, 0.75));
}
.hero__press__caption {
  display: block;
  margin-top: 0.35rem;
  text-align: center;
  font-size: 0.58rem;
  font-weight: 600;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--corona);
  opacity: 0.8;
  /* The caption sits inside the laurel's narrow box so that it centres
     on the mark rather than on the block — the laurel is pushed to one
     edge, so block-centring would park the caption beside it. nowrap
     because "Closing Night" measures slightly wider than the mark at
     28%: centred, it overhangs a few pixels each side and reads as
     deliberate, where wrapping would break it to two lines. A longer
     caption wants shorter wording, not a wider box. */
  white-space: nowrap;
  text-shadow: 0 0 10px rgba(18, 20, 26, 0.9);
}

.hero__content {
  /* Spans the full hero. The top cluster (laurels/intro/title/
     tagline/ctas) stacks from the top via justify-content: flex-start,
     so it never shifts based on .hero__quote's presence — .hero__quote
     is positioned absolutely (below) and taken out of this flow
     entirely, since it anchors to the footer's height, not to
     whatever's left over above it. See §6.1.1's "vertical order" note. */
  position: absolute;
  inset: 0;
  z-index: 2;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-start;
  text-align: center;
  gap: 1.25rem;
  padding-inline: var(--gutter);
  /* V1_REVISIONS §1.1: was clamp(5rem, 18vh, 10rem), which read as
     pinned near the nav rather than centered against the frame.
     Retuned as a percentage of hero height so it holds its relationship
     to the video regardless of viewport height — measured the actual
     horizon in the hero footage (the poster frame, sampled for its
     sky/skyline luminance transition) at ~56% down the native 16:9
     frame, and the nav's own bottom edge at ~7% of hero height. Target:
     center the cluster at the midpoint of those two, ~31.5% of hero
     height. Because the cluster's own height is a fixed px quantity at
     desktop widths (both the title image's width clamp and the
     tagline's font-size clamp are pinned to their ceilings above
     ~1309px, so the whole cluster measures a constant height regardless
     of viewport height), centering it at a fixed *percentage* of a
     variable height H requires an affine formula
     (padding-top = 0.315H - clusterHeight/2), not a pure vh proportion.
     A plain `clamp(2rem, 14vh, 8rem)` was an attempt to approximate
     that line but its 8rem ceiling is reached at H≈914px — meaning
     nearly every normal desktop height was silently pinned instead of
     tracking the target. Using calc() to express the real formula
     directly removes the approximation error; the clamp() around it is
     now just a defensive rail for pathological viewports, not the
     mechanism doing the centering. -142px is half of the cluster
     height measured live (284.07px) after the V3 swap to the real
     title PNG — the image's flatter 5.92:1 aspect ratio (vs. the text
     glyphs' ~3.23:1) made the title element, and so the whole cluster,
     shorter than the old text title produced; this constant was
     retuned to match rather than reused, so re-measure again if the
     title asset or its width clamp ever changes. */
  /* V3_REVISIONS §2.3 — the cluster drops from 31.5% to 44% of hero
     height, measured to its own midpoint. The horizon in the footage
     sits at ~56% and the nav's bottom edge at ~7%; 31.5% was the
     midpoint of those two, which read as floating in open sky well
     above the eclipse. 44% sets the cluster just clear of the horizon
     line with the eclipse above it, and holds at both 16:9 and 16:10
     because the video is object-fit: cover — any viewport narrower
     than 16:9 fills the height and crops the width, so the horizon
     stays at the same fraction of hero height in both.
     38% and not lower, and the reason is measured rather than
     aesthetic. The frame's amber horizon glow occupies 46-56% of its
     height (row-mean luminance peaks at 109/255 against ~41 in the
     upper sky), and the cluster is 22.8% of hero height tall, so a
     midpoint past ~38% pushes the CTA into that band. Sampling the
     actual rendered pixels under each element: the tagline in
     --corona-dim measures 4.22:1 at the old 31.5%, 3.60:1 here, and
     falls through 3:1 entirely by 42%. The title in --corona holds
     8.04:1 here and is never the constraint. See the Phase 2 report
     for the CTA, which is a separate and pre-existing problem that
     no vertical position fixes.
     The 24rem ceiling had to move with it. At 31.5% it was pure
     defensive rail, unreachable below the 1150px media query cut. At
     38% it would start binding well inside ordinary display heights
     and cap the cluster instead of tracking the target; 32rem pushes
     that back past any real viewport, where it is a rail again.
     -150px was half the cluster's measured height in this regime;
     it reads -138px now, the extra 12px being the V5 separation move
     described on the token itself, not a re-measurement of the
     cluster. */
  padding-top: var(--hero-cluster-top);
  padding-bottom: clamp(3rem, 8vh, 6rem);
}

.hero__intro {
  max-width: 46ch;
  font-size: 0.85rem;
  font-weight: 300;
  letter-spacing: 0.08em;
  color: var(--corona-dim);
  /* No text-transform: whatever casing is typed in config renders
     as-is (§6.1.1) — the directors may want caps or not, and that
     must be a config edit, not a code change. */
}

.hero__intro.is-empty {
  display: none;
}

.hero__title {
  /* V1_REVISIONS §1.2: the wordmark standing in for a title treatment
     was narrower than the tagline beneath it below ~648px viewport
     width — its 3.5rem floor rendered "TOTALITY" (with letter-spacing)
     at ~271px, while the tagline's own max width is ~410px. Raised the
     floor to 5rem (~388px at this letter-spacing), which stays ahead
     of the tagline's floor width (~346px) with margin across the
     whole range below the point where 11vw exceeds it — verified by
     computing both elements' widths as functions of viewport width,
     not by eye. Coefficient and ceiling unchanged; letter-spacing
     unchanged. */
  font-size: clamp(5rem, 11vw, 9rem);
  font-weight: 200;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--corona);

  /* V3_REVISIONS §0.1 — the title flash.
     The static HTML carries the text wordmark so the page works with
     no JavaScript (§10), and site.js replaces it with the real title
     artwork once config.hero.titleImage is read. Measured live: the
     text renders 75.9px of ink and the artwork 85.1px, so the swap
     grew the letterforms 12.1% and shrank the h1's box 45% (156px to
     85.1px), jerking the tagline and CTAs up ~71px with it. Whenever
     site.js arrived late enough for the parsed text to paint first —
     which on Netlify's default `max-age=0, must-revalidate` headers
     means every navigation that has to revalidate the script — that
     is exactly the "smaller title, then the larger title" the
     directors reported.
     Two halves to the fix. This one hides the text until site.js has
     decided which title to render; the :not(.is-ready) rule that
     follows reserves the artwork's own box in the meantime, so
     nothing moves when the swap lands. */
  visibility: hidden;
}

/* Added by site.js once the title is resolved, whichever path it took.
   Nothing below this class needs the placeholder box any more — the
   rendered <img> (or the text, when titleImage is null) sizes the h1
   itself from here on, so a future title asset with a different
   aspect ratio can't leave a stale reservation behind. */
.hero__title.is-ready {
  visibility: visible;
}

.hero__title:not(.is-ready) {
  /* The artwork's box, held from first layout. 900/152 is the served
     Uploadcare transform's own pixel size (see site.js's resize/900x/),
     not the 1919x324 source — the rendered height follows what is
     actually fetched. This is a pre-swap placeholder only; re-measure
     it if the title asset changes, the same way the -142px cluster
     constant on .hero__content has to be. */
  width: var(--hero-title-w);
  max-width: 100%;
  aspect-ratio: 900 / 152;
  /* .hero__title is a flex item of .hero__content (a column flex
     container), so its main axis is vertical and min-height resolves
     to `auto` — i.e. to the hidden text's own 156px content height,
     which silently beat the ratio above and left the box the text's
     size after all. Measured: without this the tagline and CTAs still
     jumped 70.9px on swap. The text is invisible here, so letting it
     overflow the reserved box is exactly what's wanted. */
  min-height: 0;
}

.hero__title--eclipse {
  /* V4_REVISIONS §0. V3 had the angle right and the direction wrong,
     and the directors flagged it twice.
     Sliding background-position moves the gradient's colour boundaries
     the OPPOSITE way across the element: the window travels forward
     through the image, so a stop that is late in the gradient reaches
     the element from the right. V3 put the dim stops first and animated
     0% -> 100%, which lit the top-right first. Measured on the running
     page before this change: at 20% progress the top-right of the Y sat
     at 100% of resting brightness while the bottom of the first T was
     at 21%, and a column scan showed the light entering at the right
     edge and sweeping left. The whole sweep was also over by ~35% of
     the duration, leaving ~2.9s of a static title.
     The fix reverses the colour order AND the direction of travel
     together — either alone just moves the problem. Lit stops now come
     first and the animation runs 100% -> 0%, so the window starts in
     the dim tail and travels back toward the lit head. Within the
     window, earlier stops sit at the lower left (the gradient line
     points up and to the right at 58deg), so the lit region appears at
     the bottom of the first T and grows up and across to the Y.
     Stop positions are computed, not guessed. At background-size
     300%, the visible window spans 32.2% of the gradient line plus
     ~3.4% for the box's height, and slides 0.644% of the line per 1%
     of position. So the window covers 0-35.6% at position 0% and
     64.4-100% at position 100%: corona must therefore reach at least
     36% for the resting state to be flat, and the dim tail must start
     no later than 64% for the first frame to be uniformly dark.
     Corona runs to 44% rather than the bare 36% minimum: at 38% the
     white band's leading edge still reached the top-right corner of
     the window at rest and left the Y measurably brighter than the
     first T (0.904 against 0.863), which is exactly the leftover edge
     §0.1 rules out. The extra margin absorbs the height term.
     Verified by scripts/check-title-animation.js. */
  background-image: linear-gradient(
    58deg,
    var(--corona) 0%,
    var(--corona) 44%,
    #FFFFFF 50%,
    #FFFFFF 56%,
    var(--hero-sweep-dim) 62%,
    var(--hero-sweep-dim) 100%
  );
  background-size: 300% 100%;
  background-position: 100% 0;
  background-repeat: no-repeat;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  /* V4_REVISIONS §0.1: 4500ms -> 6000ms. The V3 note capped this at
     4500 because a longer settle read as a page still loading — that
     reasoning is retired now that starting dark is the stated design
     rather than an artefact of the gradient. The easing is linear
     rather than V3's cubic-bezier: an eased sweep spent most of its
     time at the ends and crossed the letterforms in a rush, which is
     the opposite of a shadow moving at a constant speed. Runs once,
     never loops. */
  animation: eclipse-sweep 6000ms linear 500ms both;
}

/* Travels backwards through the gradient, which is what makes the
   light move forwards across the word — see the note above. The
   `backwards` half of the animation's `both` fill is load-bearing for
   §0.3: it applies this `from` keyframe during the 500ms delay, so the
   title is already in its dark state at the moment site.js reveals it
   and the visitor never sees lit -> dark -> lit. */
@keyframes eclipse-sweep {
  from { background-position: 100% 0; }
  to   { background-position: 0% 0; }
}

/* V3/§6.1.3: the image-title equivalent of .hero__title--eclipse.
   background-clip: text only works on text runs, so the same animated
   gradient is painted onto a pseudo-element instead and masked to the
   artwork's own alpha channel — mask-image, not background-clip. The
   underlying <img> stays in the DOM purely to establish the box's
   size/aspect ratio; this pseudo-element is fully opaque everywhere
   the mask reveals it, so the img itself is never actually seen once
   this is present. --hero-title-mask is set from site.js, since the
   mask URL is a config value, not something CSS can know in advance. */
.hero__title--image-sweep::after {
  content: "";
  position: absolute;
  inset: 0;
  /* V4_REVISIONS §0. V3 had the angle right and the direction wrong,
     and the directors flagged it twice.
     Sliding background-position moves the gradient's colour boundaries
     the OPPOSITE way across the element: the window travels forward
     through the image, so a stop that is late in the gradient reaches
     the element from the right. V3 put the dim stops first and animated
     0% -> 100%, which lit the top-right first. Measured on the running
     page before this change: at 20% progress the top-right of the Y sat
     at 100% of resting brightness while the bottom of the first T was
     at 21%, and a column scan showed the light entering at the right
     edge and sweeping left. The whole sweep was also over by ~35% of
     the duration, leaving ~2.9s of a static title.
     The fix reverses the colour order AND the direction of travel
     together — either alone just moves the problem. Lit stops now come
     first and the animation runs 100% -> 0%, so the window starts in
     the dim tail and travels back toward the lit head. Within the
     window, earlier stops sit at the lower left (the gradient line
     points up and to the right at 58deg), so the lit region appears at
     the bottom of the first T and grows up and across to the Y.
     Stop positions are computed, not guessed. At background-size
     300%, the visible window spans 32.2% of the gradient line plus
     ~3.4% for the box's height, and slides 0.644% of the line per 1%
     of position. So the window covers 0-35.6% at position 0% and
     64.4-100% at position 100%: corona must therefore reach at least
     36% for the resting state to be flat, and the dim tail must start
     no later than 64% for the first frame to be uniformly dark.
     Corona runs to 44% rather than the bare 36% minimum: at 38% the
     white band's leading edge still reached the top-right corner of
     the window at rest and left the Y measurably brighter than the
     first T (0.904 against 0.863), which is exactly the leftover edge
     §0.1 rules out. The extra margin absorbs the height term.
     Verified by scripts/check-title-animation.js. */
  background-image: linear-gradient(
    58deg,
    var(--corona) 0%,
    var(--corona) 44%,
    #FFFFFF 50%,
    #FFFFFF 56%,
    var(--hero-sweep-dim) 62%,
    var(--hero-sweep-dim) 100%
  );
  background-size: 300% 100%;
  background-position: 100% 0;
  background-repeat: no-repeat;
  -webkit-mask-image: var(--hero-title-mask);
  mask-image: var(--hero-title-mask);
  -webkit-mask-size: contain;
  mask-size: contain;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-position: center;
  mask-position: center;
  /* V4_REVISIONS §0.1: 4500ms -> 6000ms. The V3 note capped this at
     4500 because a longer settle read as a page still loading — that
     reasoning is retired now that starting dark is the stated design
     rather than an artefact of the gradient. The easing is linear
     rather than V3's cubic-bezier: an eased sweep spent most of its
     time at the ends and crossed the letterforms in a rush, which is
     the opposite of a shadow moving at a constant speed. Runs once,
     never loops. */
  animation: eclipse-sweep 6000ms linear 500ms both;
}

.hero__title--image {
  position: relative;
  display: inline-block;
}

/* The pseudo-element's gradient has real alpha of its own (corona-dim
   is 62% opacity, not opaque) — with the <img> left visible beneath it,
   that opacity blends against the opaque white PNG instead of the dark
   video, so the gradient's dim phase was rendering as solid white and
   the sweep looked inert (confirmed live: dimming only became visible
   once the img was hidden). opacity:0 (not visibility:hidden/
   display:none) keeps the img in the accessibility tree so its alt
   text still reads correctly, while letting only the animated,
   masked ::after show — the same effect background-clip:text +
   color:transparent gives the plain-text title path. */
.hero__title--image-sweep img {
  opacity: 0;
}

.hero__title--image img {
  /* V3: match the width the text wordmark occupied, not just cap it —
     the artwork is 5.92:1 (much flatter than the text glyphs' own
     ~3.23:1 width-to-height), so matching width alone measurably
     shrinks the cluster's height (confirmed live, not assumed — see
     the horizon-centering comment on .hero__content's padding-top).
     Text width scaled linearly with its own font-size at a factor of
     4.847 (measured); this is that same 11vw-based clamp multiplied
     through by 4.847, so the image tracks the exact width the text
     used to have at every viewport width, not just at the endpoints. */
  display: block;
  width: var(--hero-title-w);
  height: auto;
  /* `auto` first: the artwork's real ratio wins the moment it decodes,
     so this only governs the frames before it arrives — without it the
     img is zero-height on a cold load and the cluster jumps when the
     PNG lands (measured: 0.0336 CLS). */
  aspect-ratio: auto 900 / 152;
  margin-inline: auto;
}

.hero__title--fade {
  animation: hero-title-fade 700ms ease both;
}

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

.hero__tagline {
  /* V2_REVISIONS §1.3: was 2.5rem margin-top against .hero__ctas's
     1rem — combined with the shared 1.25rem flex gap, that's 60px
     above the tagline and 36px below it, an asymmetry that read as
     accidental. Both now match at 1.75rem so the tagline sits
     equidistant between title and CTA row (48px each side, measured
     live). */
  margin-top: 1.75rem;
  /* V3_REVISIONS §2.4: was clamp(0.8rem, 1.4vw, 0.95rem). The V2
     treatment set this deliberately tiny, following
     timeandwaterfilm.com; the directors have reversed that and want it
     to "pop more". 1.25rem at the ceiling against 0.95rem is a 32%
     increase — 20px vs 15.2px. The margins above and below are
     untouched, so the equidistant spacing V2 established (48px each
     side, title to tagline to CTA row) still holds; only the type
     inside that gap changed. */
  font-size: clamp(1rem, 1.7vw, 1.25rem);
  font-weight: 300;
  letter-spacing: 0.22em;
  text-transform: lowercase;
  /* V3_REVISIONS §2.4 — was --corona-dim. Measured against the real
     frame at the cluster's new position, --corona-dim gives 3.60:1,
     which is not AA for type this size; --corona gives 8.04:1 at the
     same spot. It also serves what §2.4 actually asks for, which is
     for the tagline to "pop more" — the hierarchy between it and the
     title is carried by 17.6px against 85px, not by dimming. Flagged
     in the Phase 2 report as a judgement call; reverting to
     --corona-dim is a one-word change. */
  color: var(--corona);
  /* Same reasoning as the nav's shadow (§1.4): this sits over moving
     video with no fixed ground behind it. Lighter than the nav's,
     because the type is larger and the band behind it is darker. */
  text-shadow: 0 0 10px rgba(18, 20, 26, 0.55);
}


/* The em dash belongs to the presentation, not to the data — config
   carries the festival's name on its own so it can never arrive with
   a dash already attached, or without one. */

.hero__ctas {
  display: flex;
  gap: 1.25rem;
  flex-wrap: wrap;
  justify-content: center;
  /* V2_REVISIONS §1.3: matches .hero__tagline's margin-top — see its
     comment. */
  margin-top: 1.75rem;
}

/* V4_REVISIONS §2.3 — the CTA, now the topmost item of the bottom
   group rather than part of the title cluster.
   The treatment reverts to an outline, and the position is the reason
   it can. V3 made this a solid fill because the CTA sat in the hero's
   amber glow band, where --horizon as text measures 1.2-1.6:1 and no
   vertical nudge fixes it. Down here on the crowd the same colour
   measures around 5:1, so the outline is legible on its own and the
   button stops shouting.
   Narrower than V3's fixed 20rem: the padding now just clears the
   label instead of stretching the box to a width inherited from the
   two buttons that used to sit side by side. */
.hero__ctas .btn {
  width: auto;
  max-width: 100%;
  padding: 0.85em 2.1em;
  font-size: 1rem;
  letter-spacing: 0.14em;
  background: transparent;
  color: var(--horizon);
  border-color: var(--horizon);
  transition:
    color 180ms ease,
    border-color 180ms ease,
    box-shadow 180ms ease,
    transform 180ms ease;
}

/* Hover and focus do NOT fill — §2.3. The border and label brighten to
   --horizon-hi, the button lifts very slightly, and a soft amber glow
   appears around it. The glow is the site's only box-shadow and the
   only exception to §4.3's rule against them; it is light around an
   eclipse, which is the one place on this site where that reads as
   subject matter rather than as decoration. */
.hero__ctas .btn:hover,
.hero__ctas .btn:focus-visible {
  background: transparent;
  color: var(--horizon-hi);
  border-color: var(--horizon-hi);
  transform: scale(1.04);
  box-shadow: 0 0 18px rgba(227, 178, 124, 0.30), 0 0 42px rgba(227, 178, 124, 0.16);
}

.hero__ctas .btn:active {
  background: transparent;
  color: var(--horizon-hi);
  border-color: var(--horizon-hi);
  transform: scale(0.98);
}

/* Colour change only under reduced motion — no scale, no glow. */
@media (prefers-reduced-motion: reduce) {
  .hero__ctas .btn,
  .hero__ctas .btn:hover,
  .hero__ctas .btn:focus-visible,
  .hero__ctas .btn:active {
    transform: none;
    box-shadow: none;
    transition: color 180ms ease, border-color 180ms ease;
  }
}

@media (max-height: 1150px) {
  /* Short-viewport compression, independent of width. .hero__quote
     anchors to a fixed distance above the footer (site.js) — that
     protects the footer's links regardless of the top cluster's
     height, but doesn't stop the cluster's own bottom edge from
     dropping below the quote on a short viewport.
     An earlier pass here tried simulating shorter viewports via
     `.hero { height: Xpx }`, which silently doesn't work for anything
     using vh units — vh always resolves against the true viewport, not
     an element's own overridden box — so it held padding-top constant
     across every "simulated" height and produced a false crossover.
     This padding-top uses the same calc()-based formula as the base
     rule (see its comment) — padding-top = 0.315H - clusterHeight/2 —
     with this regime's own cluster height. That height isn't perfectly
     constant here the way it is in the base rule: the title image's
     width clamp still scales with viewport width within this regime's
     range, so the cluster's height varies with it too. -93px below is
     half of the cluster height measured live (185.72px, at a real
     1867x905 viewport, title image at its 504.09px width ceiling)
     after the V3 swap to the real title PNG — the flatter 5.92:1
     artwork produces a visibly shorter cluster here than the old text
     title did, the same effect seen in the base rule; narrower widths
     where the title hasn't reached its ceiling will land a few points
     off the 31.5% target rather than exactly on it — an accepted
     approximation, same spirit as the base rule's own tradeoffs.
     Verified against the cluster as it actually ships today (no
     laurels, no intro line, no quote — all three are still null/
     pending per §7); if a quote is added later, re-verify the
     quote-collision floor against the real content, don't assume this
     number still holds. */
  /* Same 44% target as the base rule (§2.3), with this regime's own
     measured cluster height — 206px, so -103px, now -91px with the
     V5 separation move (see the token's own comment). The 18rem ceiling
     moves for the same reason as the base rule's: at 44% it bound
     from ~889px of height, which is most of this regime's own
     range, so it was capping rather than railing. 26rem clears the
     regime's 1150px ceiling entirely.
     On :root and not on .hero__content, so the eclipse layer sees the
     same number the cluster does — see the base declaration. The
     padding this feeds is unchanged: .hero__content inherits it. */
  :root {
    --hero-cluster-top: clamp(2rem, calc(38vh - 91px), 26rem);
  }

  .hero__content {
    gap: 0.45rem;
  }

  .hero__title {
    /* V1_REVISIONS §1.2: same wordmark-vs-tagline fix as the base rule
       (see its comment) — this regime's old 3.5rem ceiling rendered
       the title at ~271px wide against the tagline's ~410px at
       essentially any short-viewport width above ~800px, since 7vw
       reaches that ceiling well before the tagline maxes out. Raised
       to clamp(5rem, 10vw, 6.5rem): floor width (~388px) clears the
       tagline's floor width (~346px), and the new 6.5rem ceiling
       (~504px) clears the tagline's own ceiling (~410px). */
    font-size: clamp(5rem, 10vw, 6.5rem);
  }

  /* V3: same width-matching approach as the base rule, using this
     regime's own 10vw coefficient and 5rem/6.5rem bounds run through
     the same 4.847 width-per-fontsize factor. Set on the token so the
     pre-swap placeholder box (.hero__title:not(.is-ready)) compresses
     with the image rather than reserving the base regime's width. */
  :root {
    --hero-title-w: clamp(387.76px, 48.47vw, 504.09px);
  }

  .hero__tagline {
    /* V2_REVISIONS §1.3: matches .hero__ctas's margin-top below, same
       reasoning as the base rule. */
    margin-top: 0.5rem;
    /* V3_REVISIONS §2.4, measured rather than assumed: this regime
       caps the title at 504px, and the base rule's 1.25rem ceiling
       set the tagline 528px wide — wider than the title it sits
       under, which is the exact relationship V1_REVISIONS §1.2 went
       to some trouble to fix in the other direction. At 1.1rem the
       tagline measures ~465px and stays clearly inside the title's
       width. The base rule keeps its larger ceiling because the base
       regime's title runs up to 698px, where 528px is comfortably
       narrower. */
    font-size: clamp(1rem, 1.4vw, 1.1rem);
  }

  .hero__intro {
    font-size: 0.75rem;
  }

  .hero__ctas {
    margin-top: 0.5rem;
  }

}

/* V3_REVISIONS §2.2 — the eclipse below the stacking breakpoint.
   §2.2 asks for this to be verified at mobile widths against cropping
   and against collision with the title. It collides, and the reason is
   structural rather than a matter of nudging the default.
   config.hero.eclipse places the eclipse at a percentage of hero
   HEIGHT, while the title's own position stops being a percentage of
   hero height as soon as the layout stacks: below 1100px the column
   picks up the laurel row and the quote and is centred in what is left
   between the nav and the footer, which lifts the title a long way up.
   Measured, the clear sky between the nav's bottom edge and the top of
   the title is 165px at 820x1180, 80px at 430x932, 46px at 390x844 and
   20px at 360x740 — against an eclipse that is 22% of hero width, or
   180px, 95px, 86px and 79px at those same sizes. It does not fit at
   any of them, and at 1024x768 and 390x844 it rendered directly on the
   wordmark's letterforms.
   Shrinking it to fit would leave a 40px mark at 390px and nothing
   workable at 360px, so it is hidden here instead and kept where it
   was designed to live. Flagged in the Phase 2 report: giving the
   directors a real mobile eclipse means its own size and position
   values in config, which is a schema decision rather than a CSS one.
   The layer is still built, still config-driven, and still hides on a
   null image — this only controls where it renders. */
/* The nav's items collapse to the Menu toggle at 820px and the bar
   gets 2px taller (measured 77.39px against 75.39px above it). The
   eclipse's clearances are computed from this, so it has to follow. */
@media (max-width: 820px) {
  :root {
    --hero-nav-bottom: 4.875rem;
  }
}

@media (max-width: 1100px) {
  /* Hidden by default below the breakpoint — that is the documented
     fallback when config.hero.eclipseMobile is null. site.js adds
     .has-mobile only when that key carries values. */
  .hero__eclipse {
    display: none;
  }

  .hero__eclipse.has-mobile {
    display: block;
  }

  /* This rule used to drop the base rule's caps entirely, on the
     reasoning that the stacked layout states its placement outright
     and a cap would only fight it. That was true of a 13.5%-wide
     square, whose half-height was under 7px — it could not reach
     anything whatever the viewport did. It is not true of a 94%-wide
     4.1:1 strip: at 1024x768 that is 963px wide and 235px tall
     dropped into a 113px band, which lands on the title (measured
     peak alpha 25) and on the nav's own links (peak alpha 130). So
     the band logic applies here too, with this breakpoint's own
     numbers. Everything that depends on the aspect has to be restated
     against --hero-eclipse-ar-m; --hero-eclipse-band and
     --hero-eclipse-maxh do not, since the band is the same gap
     whatever is being fitted into it.

     Below the breakpoint the <picture> is serving the mobile file,
     which has its own aspect: site.js sets --hero-eclipse-ar-m from
     config.hero.eclipseMobile.ratio, falling back to the desktop
     ratio when no mobile file is configured, since the desktop <img>
     is then what renders down here. */
  .hero__eclipse.has-mobile img {
    --hero-eclipse-d: min(
      var(--hero-eclipse-size-m),
      calc(var(--hero-eclipse-ar-m, 1) * var(--hero-eclipse-maxh))
    );
    --hero-eclipse-half: calc(var(--hero-eclipse-d) / var(--hero-eclipse-ar-m, 1) / 2);
    left: var(--hero-eclipse-x-m);
    top: clamp(
      calc(var(--hero-nav-bottom) + 16px + var(--hero-eclipse-half)),
      var(--hero-eclipse-y-m),
      max(
        calc(var(--hero-nav-bottom) + 16px + var(--hero-eclipse-half)),
        calc(var(--hero-cluster-top) - 16px - var(--hero-eclipse-half))
      )
    );
    width: var(--hero-eclipse-d);
    aspect-ratio: var(--hero-eclipse-ar-m, 1);
  }
}

/* The floor for a small-screen eclipse, found by measurement rather
   than chosen, and RE-MEASURED when the artwork changed. It has to sit
   in the gap between the nav's bottom edge and the top of the title,
   clear both, crop at no edge, and still render at least 6px of actual
   disc — below that it stops reading as an eclipse and starts reading
   as a dead pixel.

   Against the old square eclipse the floor was 381px: at 375x812 the
   alpha reaching into the letterforms peaked at 113 and at 360x740 at
   20, and shrinking the layer to escape that took the disc under 6px.

   The V5 strip changes that measurement, so the number moved with it.
   The strip is wide and shallow — 94% of hero width but only a
   quarter of that tall — so it needs far less vertical room than a
   disc of the same width, and its own phases are much larger than the
   old one's at the same viewport. Re-measured as real alpha against
   the title's bounding box, not as bounding boxes: at 375x812 the
   nearest alpha stops 45.7px above the title and peak alpha inside
   the title box is 0, and at 360x740 it stops 30.5px above with peak
   alpha 0 again. Both clear, so the floor drops to 340px.

   340 is itself the measured edge, not a round number: at 341x800 the
   layer clears the title by 46.7px with peak alpha 0, and at 320x568
   it lands on the letterforms at peak alpha 255. The rendered disc
   runs 22-25px through this whole range, so the 6px legibility floor
   is no longer anywhere near binding. */
@media (max-width: 340px) {
  .hero__eclipse.has-mobile {
    display: none;
  }
}

/* The same floor as the rule above, keyed on viewport HEIGHT instead
   of width, and derived the same way — from what actually reads,
   checked at 1:1, rather than from taste.

   Why a height floor is needed at all. The band clamp above never
   lets the layer collide with anything; it just shrinks the strip
   until it fits between the nav and the title. On a short viewport
   that is a quiet failure rather than a loud one: the band is a
   function of height alone, so it keeps closing, and the strip keeps
   thinning, long after the phases have stopped reading. Disc diameter
   works out to almost exactly 0.30 * --hero-eclipse-maxh for BOTH
   assets — a coincidence of how they were generated, but a useful
   one, because it means a single height threshold governs the desktop
   strip and the mobile one alike, at every width.

     vh    band   max strip h   strip w   disc
     611     53        21          171      6.2
     620     57        25          199      7.2
     628     60        28          224      8.1
     634     62        30          242      8.7
     640     64        32          261      9.4
     660     72        40          322     11.6
     700     87        55          445     16.7

   WHY 640 AND NOT 607. Running the old 6px-of-actual-disc floor
   through the table puts the crossing at vh 607, and that was the
   first candidate. Looked at on the real page at 1:1 it is wrong, and
   wrong for a reason specific to this artwork: the 6px floor was
   written for a SINGLE disc, where 6px is enough to tell a ringed
   disc from a dead pixel. What has to read here is the SEQUENCE. If
   only the central disc resolves and the eight partial phases around
   it are dots, the thing the V5 artwork exists to show is gone, and
   what is left is a worse version of the single eclipse it replaced.

   Judged at 1:1 at 1366 wide: at 611 (6.2px) and 620 (7.2px) the row
   is specks; at 628 (8.1px) the central disc resolves but the outer
   crescents are still dots; 634 (8.7px) is the first height where the
   crescents show shape; by 640-641 (9.4px) it is unambiguous. 640 is
   the unambiguous end rather than the 634 edge, because a one-pixel
   margin on a perceptual call is not a margin.

   Checked at the other end too, since narrow widths are the worst
   case at any given height — the mobile asset's discs are a smaller
   share of its width and the nav is 2px taller below 820px. At
   430x641 the strip is 125px wide with 8.7px discs and still reads as
   nine phases.

   Both selectors, deliberately. `.hero__eclipse.has-mobile` carries
   (0,2,0) in the max-width:1100px block above, so a bare
   `.hero__eclipse` here at (0,1,0) would lose to it below the
   breakpoint however late it appears — media queries add no
   specificity. The pair matches it and wins on source order.

   This also covers phones held in landscape, where the band goes
   negative and the layer is degenerate before it is merely small. */
@media (max-height: 640px) {
  .hero__eclipse,
  .hero__eclipse.has-mobile {
    display: none;
  }
}

/* V4_REVISIONS §2.8 — the phone layout.
   V3 fought to keep the whole hero inside one viewport on a phone,
   with a centred column, a measured footer reserve and per-size trims.
   §2.8 retires that: "the home page may exceed one viewport on
   phones; that's acceptable." So below the breakpoint the hero simply
   grows and everything below the title sits in normal flow, in the
   order §2.8 gives — CTA, endorsement, logo, links, copyright.
   `display: contents` on .hero__bottom is what allows the endorsement
   to land between the CTA and the footer rows: it dissolves the
   wrapper so the CTA and the footer become flex children of .hero
   alongside the endorsement, which puts all three in one ordering
   context. Without it, `order` could only shuffle items inside the
   wrapper.
   .hero__content stays out of flow and keeps its own offset, so the
   title sits at the same fraction of the viewport as on desktop and
   the approved eclipse-to-title relationship holds at every width. */
@media (max-width: 900px) {
  .hero {
    height: auto;
    min-height: 100svh;
    display: flex;
    flex-direction: column;
    /* Below the photograph the hero continues on the page's own
       ground. --ground-base is the frame's exact bottom-edge value
       (§4.1), which is the whole reason the site can do this without a
       visible seam — the same trick the trailer section used to use
       under the hero. */
    background: var(--ground-base);
    /* Where the flow content starts. Not a spacing choice: on a
       portrait crop the frame's glow band sits around 46-56% of the
       hero and the crowd does not begin until roughly 70%, and the
       CTA's contrast follows that directly. Measured at 390x844 with
       the content starting at 62svh, --horizon on the label came to
       1.86:1; at 74svh it is on the crowd instead. */
    padding-top: 74svh;
    padding-bottom: 2.5rem;
    gap: 1.6rem;
  }

  /* The photograph covers exactly one viewport. Letting it cover the
     whole grown hero instead would crop the frame to a tall narrow
     slice and drag the glow band down over the content. */
  .hero__media,
  .hero__scrim {
    height: 100svh;
    bottom: auto;
  }

  /* Nothing left to darken down here — the footer sits on flat ground,
     not on the photograph. */
  .hero__bottom .site-footer--overlay {
    background: none;
  }

  .hero__bottom {
    display: contents;
  }

  .hero__ctas {
    order: 1;
    position: relative;
    z-index: 3;
    margin-top: 0;
  }

  .hero__endorsement {
    order: 2;
    position: static;
    width: auto;
    max-width: min(34ch, 86vw);
    margin-inline: auto;
  }

  /* Centred rather than right-aligned: with nothing beside it there is
     no right edge for the laurel to meet, and §2.8 asks for the laurel
     centred beneath the quote. */
  .hero__endorsement__laurel {
    width: 46%;
    margin-inline: auto;
  }

  /* V6: the press block joins §2.8's stack directly below the
     endorsement — CTA, endorsement, press, logo, links, copyright.
     FNC first because it is the world premiere, Variety second.
     The footer moves from order 3 to 4 to make room; two items
     sharing order 3 and relying on source order to break the tie
     would work today and break the first time the markup moves. */
  .hero__press {
    order: 3;
    position: static;
    width: auto;
    max-width: min(34ch, 86vw);
    margin-inline: auto;
  }

  /* Centred rather than left-aligned, for the same reason the
     endorsement's laurel centres down here: stacked in one column
     there is no edge left for the mark to meet. 26% holds the
     weight match with the endorsement's 46% at this width. */
  .hero__press__laurel {
    width: 26%;
    margin-inline: auto;
  }

  /* V6: both quotes return to centred here. The outer-edge alignment
     they use on desktop exists so each one agrees with a laurel pinned
     to that edge; stacked in a single centred column there is no outer
     edge left to agree with, and flush text sitting under a centred
     mark reads as a mistake rather than as a choice. Same reasoning
     that centres the laurels themselves above. */
  .hero__endorsement__quote,
  .hero__press__quote,
  .hero__press__source {
    text-align: center;
  }

  .hero__bottom .site-footer--overlay {
    order: 4;
    padding-bottom: 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .hero__title--eclipse,
  .hero__title--fade {
    animation: none;
    background-image: none;
    -webkit-background-clip: initial;
    background-clip: initial;
    color: var(--corona);
  }

  /* Defensive, matching the text path above: site.js already gates
     adding .hero__title--image-sweep on !prefersReducedMotion(), so
     this is a backstop, not the primary defence. Hides the animated
     pseudo-element entirely rather than freezing it mid-gradient,
     leaving the plain static <img> visible underneath. */
  .hero__title--image-sweep::after {
    display: none;
  }
}

/* ---------- Trailer ---------- */

.trailer {
  /* Below the hero, the page begins at exactly the frame's own
     bottom-edge value — no visible seam where the photo ends (§4.1). */
  background: var(--ground-base);
  text-align: center;
}

.trailer__inner {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2rem;
}

.trailer__embed {
  position: relative;
  width: 100%;
  max-width: 1000px;
  aspect-ratio: 16 / 9;
  background: var(--ground-rise);
}

.trailer__embed iframe {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
}

.trailer__poster {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--ground-rise);
  cursor: pointer;
}

.trailer__poster img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.trailer__play {
  position: relative;
  z-index: 1;
  width: 64px;
  height: 64px;
  border-radius: 50%;
  border: 1px solid var(--corona);
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--corona);
  transition: background-color var(--motion-fast), color var(--motion-fast);
}

.trailer__poster:hover .trailer__play,
.trailer__poster:focus-visible .trailer__play {
  background: var(--corona);
  color: var(--ground-deep);
}

.trailer__synopsis {
  text-align: left;
  color: var(--corona-dim);
}

.trailer.is-hidden {
  display: none;
}

/* ---------- Image bands ---------- */
/* V1_REVISIONS §2: one component, two variants. Title bands sit behind
   a page's own <h1> and crop to a fixed height with a scrim for
   legibility. Interstitials sit between content sections at native
   aspect ratio with no crop and no scrim — they're meant to read
   bright against the dark page, not be pulled toward the palette. */

.band {
  /* V2_REVISIONS §0.5: bands are <section> elements that are direct
     children of <main>, so without this they silently inherit
     `main > section { padding-block: var(--rhythm) }` (the page's
     generic content-spacing rule, ~72px/side at typical widths). With
     box-sizing: border-box, that padding eats into the section's own
     explicit height — measured live: a .band--title with height:34vh
     (389px border-box here) had only a 245px content box once 144px
     of unintended vertical padding was subtracted, which is exactly
     where "34vh measures closer to 175px" at typical desktop heights
     comes from. Confirmed by testing a bare element with the same
     class outside any band context: padding-block:0 fixed it
     immediately, with no other property involved. Bands are
     full-bleed, edge-to-edge components — they should never inherit
     ambient section spacing in the first place. */
  padding-block: 0;
  position: relative;
  width: 100%;
  overflow: hidden;
}

.band.is-hidden {
  display: none;
}

.band__img {
  display: block;
  width: 100%;
}

.band--title {
  /* V2_REVISIONS §2: rebuilt from a thin strip (34vh, measured closer
     to 175px after the §0.5 padding leak) into an actual page-header
     section — tall enough to hold a title and a subheader inside the
     image itself, with a floor so it never gets too short to read as
     a photograph on a short viewport. */
  height: 55vh;
  min-height: 380px;
}

.band--title .band__img {
  height: 100%;
  object-fit: cover;
  /* Overridden per band from config.bands.<key>.focus (§5.2/§7.2).
     Centre is the default and most bands keep it. */
  object-position: 50% 50%;
}

.band--title .band__scrim {
  position: absolute;
  inset: 0;
  /* V2_REVISIONS §2: a flat overlay ends at a hard horizontal line —
     confirmed still visible after the §0.5 padding fix (that fix
     corrected the band's height, not the scrim's shape; measured live,
     the seam was still there). This is a vertical dissolve instead:
     light at the top, deepening to fully opaque --ground-base
     (rgb(23,21,25) — matching the page's own background exactly, not
     an approximation) at the bottom edge, so there's no colour
     discontinuity left to see.
     The midpoint alpha is genuinely different per image — measured
     against each real image's actual pixels in its text-bearing zone,
     not assumed from "looks bright" or "looks dark" — glassesFactoryWall
     reads dark overall but needed the *highest* alpha of the four
     because the specific band across its text zone has bright,
     saturated patches; glassesOnLawn needed the lowest despite being
     the brightest image overall, because its text zone lands on
     mid-toned sky and grass. Set per placement via --band-scrim-mid
     (falls back to 0.45 if a placement doesn't set one). */
  background: linear-gradient(
    to bottom,
    rgba(23, 21, 25, calc(var(--band-scrim-mid, 0.45) * 0.4)) 0%,
    rgba(23, 21, 25, var(--band-scrim-mid, 0.45)) 50%,
    rgba(23, 21, 25, 1) 100%
  );
}

.band--title .band__content {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.75rem;
  text-align: center;
}

.band--title .band__content .text-page-title {
  margin: 0;
}

/* V3_REVISIONS §4.3/§5.3 — the page title placed over a chosen part of
   its header image rather than centred. Both are desktop compositions,
   so this is scoped above the nav breakpoint; below it the base rule's
   centring stands, unmodified, because the image crops to a different
   part of the frame at those widths and the grass or the sweatshirts
   the title was placed over may not be in shot at all.
   The wrapper carries .container, whose max-width and auto margins are
   what centre it; taking it out of `inset: 0` and shrink-wrapping it
   at an explicit point turns both of those off, which is why the
   padding and width are reset here rather than left to fight. */
@media (min-width: 821px) {
  .band--title.has-title-position .band__content {
    inset: auto;
    left: var(--band-title-x);
    top: var(--band-title-y);
    transform: translate(-50%, -50%);
    width: max-content;
    /* Never wider than the band it sits in, however long a title
       gets or however close to an edge it is placed. */
    max-width: min(46ch, 84vw);
    padding-inline: 0;
  }
}

.band__subhead {
  max-width: 46ch;
  color: var(--corona-dim);
}

.band__credit {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0.75rem;
  margin: 0;
  padding-inline: var(--gutter);
  font-size: 0.75rem;
  color: var(--corona-dim);
  opacity: 0.7;
  text-align: center;
}

.band--interstitial .band__img {
  height: auto;
  max-height: 70vh;
  margin-inline: auto;

  /* V3_REVISIONS §6.5 — the "black borders" the directors asked for,
     done as a max-width with the page ground showing at the sides.
     Explicitly NOT bars baked into the image files: bars in the file
     are what caused the distortion they were complaining about in the
     first place (§0.4 documents the new balloon arriving with exactly
     that defect, 3996x2997 with black letterbox baked in and the
     content stretched ~4% horizontally), and a baked bar is also a
     fixed proportion that stops matching the moment the window
     changes.
     This is the behaviour they described: a frame that grows while the
     image stays put. Past 1100px the image holds its size and the
     ground opens up at the sides; below it the image simply fills the
     width as before, because a phone has no width to give away.
     1100px chosen against the page's own 1200px content max-width — a
     little narrower, so the interstitials read as inset from the text
     columns rather than accidentally flush with them. */
  max-width: 1100px;
  width: 100%;
}

.band--interstitial {
  background: transparent;
}

.band--interstitial .band__caption {
  max-width: 46ch;
  margin-inline: auto;
  margin-top: 0.75rem;
  padding-inline: var(--gutter);
  font-size: 0.85rem;
  line-height: 1.5;
  color: var(--corona-dim);
  text-align: center;
}

/* V4_REVISIONS §4.3 — the Media contact and Press kit panels.
   Deliberately the same treatment as .featured__item on the Screenings
   page: an announcement with one amber edge on top and hairlines
   elsewhere, not a card with four accented sides. Sharing the
   treatment is the point — these are the same kind of object. */
.contact-panels {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 2rem;
  max-width: 900px;
  margin-inline: auto;
}

.contact-panel {
  background: var(--ground-rise);
  border: 1px solid var(--rule);
  border-top: 2px solid var(--horizon);
  border-radius: 2px;
  padding: 2rem;
}

@media (max-width: 820px) {
  .contact-panels {
    grid-template-columns: 1fr;
  }
}

/* The topic dropdown (§4.4). A <select> inherits almost nothing by
   default, so it is matched to the text inputs explicitly rather than
   left as the platform control. */
.form select {
  width: 100%;
  padding: 0.75rem 0.9rem;
  font: inherit;
  color: var(--corona);
  background: var(--ground-deep);
  border: 1px solid var(--rule);
  border-radius: 2px;
  appearance: none;
  /* The disclosure arrow, drawn rather than imported — no icon font
     and no extra request, consistent with the social icons. */
  background-image: linear-gradient(45deg, transparent 50%, var(--corona-dim) 50%),
                    linear-gradient(135deg, var(--corona-dim) 50%, transparent 50%);
  background-position: calc(100% - 20px) calc(50% + 2px), calc(100% - 14px) calc(50% + 2px);
  background-size: 6px 6px, 6px 6px;
  background-repeat: no-repeat;
  padding-right: 2.75rem;
}

.form select:focus-visible {
  outline: 2px solid var(--horizon);
  outline-offset: 3px;
}

/* Until a real choice is made the control shows its placeholder, which
   should read as a prompt rather than as an answer. */
.form select:invalid {
  color: var(--corona-dim);
}

/* V4_REVISIONS §5 — the Press page's festival laurel row.
   This is the width-sharing behaviour V3 built for the hero: each mark
   takes an equal share of the row, so one, two or four all lay out
   without a rule per case. The hero's own laurel is a different thing
   now (one quote's signature), which is why the behaviour lives here
   rather than being shared with it. */
/* Centred to match the row beneath it — a left-aligned heading over a
   centred row reads as an orphan, and the press-contact block on the
   same page is centred for the same reason. */
.press-laurels {
  text-align: center;
}

.press-laurels__row {
  display: flex;
  align-items: center;
  justify-content: center;
  /* V7: 2.5rem while the row held one mark, where the gap never
     rendered. With two it does, and 2.5 was tight: the FNC lockup
     carries its text hard against its right edge and the DOC NYC
     wreath starts hard at its left, so the two sets of ink nearly
     touched even though their boxes did not. Compared 2.5 / 3.5 / 4.5
     side by side — at 4.5 they stop reading as a set and become two
     separate marks, 3.5 is where they breathe and still group. */
  gap: 3.5rem;
  margin-top: 1.5rem;
  flex-wrap: wrap;
}

.press-laurels__row img {
  flex: 0 0 auto;
  /* V7: normalised by HEIGHT, not width. Until now the row shared its
     width equally between the marks, which was invisible while there
     was only ever one of them in it. With two it is wrong: these come
     from different festivals and are different shapes, so equal width
     gives them different heights, the row loses its baseline, and the
     wider mark reads as the smaller one. A shared cap height is what
     makes a set of laurels read as a set — it is how they sit on a
     poster or in a press kit, and it is shape-independent, so a third
     and fourth mark need no rule of their own.
     The clamp does the responsive work the old max-width did: 13vw
     between a 96px floor and a 165px ceiling. Two marks at 165 plus
     the 2.5rem gap come to about 417px, and flex-wrap catches any
     count that outgrows the row. */
  height: clamp(96px, 13vw, 165px);
  width: auto;
  /* Each mark's own ratio, set per image by site.js from
     config.press.laurels[].ratio — the same route hero.eclipse.ratio
     takes to --hero-eclipse-ar. With the height fixed above, this is
     what reserves the correct width before the file decodes, so the
     row does not reflow as images land. It replaces a single
     hardcoded ratio that was only ever right because the row held one
     mark. The fallback is the mean of the two marks the site carries,
     so a laurel added to config without a ratio is a few pixels out
     for one frame rather than zero-width. */
  aspect-ratio: var(--laurel-ar, 1.14);
  /* White at reduced opacity for the soft grey, as on the home page.
     No drop-shadow here — this sits on flat ground, not on a
     photograph. */
  opacity: 0.68;
}

/* A review's own laurel, when it has one — its signature, sitting
   under the quote the way the home page endorsement's does. */
.review__laurel {
  margin-top: 1rem;
}

.review__laurel img {
  width: 140px;
  max-width: 45%;
  height: auto;
  /* Deliberately left at the old FNC ratio and deliberately not
     updated with the others in V7. This slot is generic — a review's
     own laurel could come from any festival or publication, so no
     single ratio is right for it, and config.press.reviews is empty,
     so nothing renders through this rule today. 1666 / 1588 is a
     placeholder from when FNC was the only mark the site had. When a
     review arrives carrying a laurel, either drop this line or carry
     the ratio per review; the only cost of a wrong value here is a
     one-frame reflow as the file lands. */
  aspect-ratio: auto 1666 / 1588;
  opacity: 0.68;
}

/* V4_REVISIONS §6.3 — the Sandbox logo above its About section.
   Sized from the same --footer-logo-width the footer uses, scaled up
   so it anchors a full section rather than a footer row: 1.6x of 147px
   is ~235px. That is well past the 113px this 227x38 source renders
   crisply at on a 2x display, so it is knowingly soft — §6.3 says so,
   and the incoming higher-resolution file fixes both places at once
   because both read the same config entry. */
.sandbox-boilerplate__logo {
  text-align: center;
  margin-bottom: 1.25rem;
}

.sandbox-boilerplate__logo.is-empty {
  display: none;
}

.sandbox-boilerplate__logo img {
  width: calc(var(--footer-logo-width, 147px) * 1.6);
  max-width: 70%;
  height: auto;
  margin-inline: auto;
  /* Reserves the box before decode, so the section below it does not
     jump when the file lands. */
  aspect-ratio: auto 227 / 38;
  filter: brightness(0) invert(1);
  opacity: 0.85;
}

/* ---------- Screenings page ---------- */

.page-header {
  padding-top: clamp(7rem, 12vw, 9rem);
  padding-bottom: 2rem;
  text-align: center;
}

.featured {
  padding-block: var(--rhythm);
}

.featured[hidden] {
  display: none;
}

.featured__inner {
  display: grid;
  gap: 2.5rem;
}

.featured--one .featured__inner {
  grid-template-columns: 1fr;
  max-width: 560px;
  margin-inline: auto;
  text-align: center;
}

.featured--two .featured__inner {
  grid-template-columns: 1fr 1fr;
}

.featured--three .featured__inner {
  grid-template-columns: repeat(3, 1fr);
}

.featured__item {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  /* V1_REVISIONS §3.2: an announcement, not a list item — one amber
     edge on top, hairline on the other three, not four orange sides
     competing with the ticket button inside. */
  background: var(--ground-rise);
  border: 1px solid var(--rule);
  border-top: 2px solid var(--horizon);
  border-radius: 2px;
  padding: 2rem;
}

.featured--one .featured__item {
  align-items: center;
}

.featured__item .btn {
  margin-top: 0.75rem;
  align-self: flex-start;
}

.featured--one .featured__item .btn {
  align-self: center;
}

@media (max-width: 820px) {
  .featured--two .featured__inner {
    grid-template-columns: 1fr;
  }
}

@media (max-width: 1000px) {
  .featured--three .featured__inner {
    grid-template-columns: 1fr;
  }
}

.schedule {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 3rem;
  padding-block: var(--rhythm);
}

.schedule--single {
  grid-template-columns: 1fr;
  max-width: 600px;
  margin-inline: auto;
  /* V2_REVISIONS §0.3: the box itself was already centred (max-width +
     margin-inline: auto, confirmed by measuring its rect — it landed
     exactly on the page's centre line). What read as "left of centre"
     was the text inside it: .schedule__column has no text-align, so
     rows sit flush against the box's own left edge, which looks
     off-centre next to blocks like .booking that explicitly centre
     their text. Two-column desktop mode is unaffected — this only
     applies once the layout has collapsed to one column. */
  text-align: center;
}

@media (max-width: 820px) {
  .schedule {
    grid-template-columns: 1fr;
  }
}

.schedule__column summary {
  cursor: default;
  list-style: none;
}

.schedule__column summary::-webkit-details-marker,
.schedule__column summary::marker {
  display: none;
  content: "";
}

@media (max-width: 820px) {
  .schedule__column--past summary {
    cursor: pointer;
  }

  .schedule__column--past summary::after {
    content: " (tap to show)";
    color: var(--corona-dim);
    font-size: 0.8rem;
  }

  .schedule__column--past[open] summary::after {
    content: " (tap to hide)";
  }
}

/* V2_REVISIONS §0.1/§0.2: this used to be
   `@media (min-width: 821px) { .schedule__column--past:not([open]) >
   *:not(summary) { display: block; } }` — an attempt to force a
   closed <details> to show its body on desktop without setting
   [open]. Measured live: it doesn't work in current Chrome. A closed
   <details>'s non-summary content lives in an internal box the
   browser does not paint or size regardless of an author `display`
   override — confirmed by testing a plain, unrelated <details> the
   same way, and by testing that literally setting the `open` attribute
   (nothing else) fixed it immediately. site.js now sets `open` on
   #schedule-past directly, matched to the same 821px breakpoint this
   rule used to gate on — see initScreenings(). */

.schedule__row {
  padding-block: 1.25rem;
}

.schedule__row + .schedule__row {
  border-top: 1px solid var(--rule);
}

.schedule__row p {
  margin-top: 0.35rem;
}

.schedule__row p:first-child {
  margin-top: 0;
}

.schedule__row--past,
.schedule__row--past .text-screening-date {
  color: var(--corona-dim);
}

.schedule__type {
  font-size: 0.85rem;
}

.schedule__type--current {
  color: var(--horizon);
}

.schedule__empty {
  color: var(--corona-dim);
}

.schedule__expander {
  display: inline-block;
  margin-top: 1.25rem;
  color: var(--horizon);
  font-size: 0.9rem;
  text-underline-offset: 0.25em;
  text-decoration: underline;
  text-decoration-thickness: 1px;
  transition: color var(--motion-fast);
}

.schedule__expander:hover,
.schedule__expander:focus-visible {
  color: var(--corona);
}

.schedule__expander[hidden] {
  display: none;
}

.booking {
  /* V1_REVISIONS §3.3: reads as the destination of the page — a card
     that lifts off it, not another full-width section. */
  text-align: center;
  padding-block: var(--rhythm);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1rem;
  max-width: 640px;
  margin-inline: auto;
  background: var(--ground-rise);
  border: 1px solid var(--rule);
  border-radius: 2px;
}

/* ---------- Forms ---------- */

.form {
  display: flex;
  flex-direction: column;
  gap: 1.5rem;
  max-width: 46rem;
  margin-inline: auto;
  text-align: left;
}

.form--split {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1.5rem;
}

@media (max-width: 600px) {
  .form--split {
    grid-template-columns: 1fr;
  }
}

.field {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.field label {
  font-size: 0.85rem;
  letter-spacing: 0.02em;
  color: var(--corona-dim);
}

.field input,
.field textarea {
  background: var(--ground-rise);
  border: 1px solid var(--rule);
  border-radius: 2px;
  padding: 0.75em 1em;
  color: var(--corona);
  font-family: inherit;
  font-size: 1rem;
}

.field input:focus-visible,
.field textarea:focus-visible {
  outline: 2px solid var(--horizon);
  outline-offset: 2px;
}

.field__error {
  color: var(--horizon-raw);
  font-size: 0.85rem;
}

.field__error[hidden] {
  display: none;
}

.field--honeypot {
  position: absolute;
  left: -9999px;
}

.form__status {
  color: var(--corona-dim);
  font-size: 0.9rem;
}

.form__status[hidden] {
  display: none;
}

.form .btn {
  align-self: flex-start;
}

/* V2_REVISIONS §4.3: "Send request" read as an afterthought sitting
   left-aligned under a full-width form. Stretches to the same width
   as the fields above it, with its label centred rather than
   left-aligned the way the field labels are.
   Needs the .form ancestor in the selector, not just .btn--block alone
   — .form .btn (two classes) otherwise wins on specificity over
   .btn--block (one class) regardless of source order, and the
   align-self: flex-start above keeps the button its own width. */
.form .btn--block {
  align-self: stretch;
  text-align: center;
}

/* Reuses the nav's horizon-gradient hairline (see .site-nav::after) as
   a top rule on a lifted surface — V1_REVISIONS §4.1: same "this is
   the action" signal as the amber nav rule, without inverting to a
   light card, which would read as a third-party embed on a dark
   cinematic site. */
.form-surface {
  position: relative;
  background: var(--ground-sky);
  border: 1px solid var(--rule);
  border-radius: 2px;
  padding: clamp(2rem, 5vw, 3rem);
}

.form-surface::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 1px;
  pointer-events: none;
  background: var(--horizon-gradient);
}

/* Visually hidden but still available to assistive tech — used on the
   compact stay-in-touch fields below instead of removing labels
   outright. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* Compact stay-in-touch signup — V1_REVISIONS §4.5. Used in the
   engagement modal, stay-in-touch.html, and the foot of the About
   page: same markup and behaviour everywhere, styled to read as one
   light block rather than a second long form. Hiding the labels
   visually (kept for screen readers via .sr-only) removes the root
   cause of the old button/field baseline bug — a label sitting above
   the input pushing it down relative to an unlabelled button — for
   every field, not just email, regardless of how they wrap.
   The button gets its own full-width row via flex-basis: 100% rather
   than being left to wrap wherever flex-wrap happens to break: at the
   modal's ~560px width the four fields alone (measured) need more
   width than fits on one line, so leaving the button to wrap
   naturally left it stranded ~120px below the email field on its own
   line anyway — pinning it to always start a fresh row is the more
   deliberate version of what was already happening, not a workaround. */
.form--compact {
  flex-direction: row;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.75rem;
  max-width: 640px;
}

.form--compact .field {
  flex: 1 1 140px;
  gap: 0;
}

.form--compact .field--email {
  flex-basis: 200px;
}

.form--compact .btn {
  flex: 0 1 100%;
  align-self: center;
  margin-top: 0.25rem;
}

.form--compact .field__error {
  font-size: 0.75rem;
}

.form--compact .form__status {
  flex-basis: 100%;
}

@media (max-width: 500px) {
  .form--compact {
    flex-direction: column;
    align-items: stretch;
  }

  .form--compact .btn {
    align-self: stretch;
    text-align: center;
  }
}

/* ---------- Document links ---------- */

[data-document].is-hidden {
  display: none;
}

/* ---------- Partners strip ---------- */

.partners {
  text-align: center;
  padding-block: var(--rhythm);
}

.partners.is-hidden {
  display: none;
}

.partners__list {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 2.5rem;
  margin-top: 2rem;
}

.partners__item img {
  height: 32px;
  width: auto;
  filter: grayscale(1) brightness(1.6);
  opacity: 0.75;
  transition: opacity var(--motion-fast);
}

.partners__item:hover img,
.partners__item:focus-visible img {
  opacity: 1;
}

/* ---------- Stay in touch ---------- */
/* The form itself is the shared .form--compact component (see the
   Forms section) — this just centers whichever block it sits in,
   whether that's stay-in-touch.html's own section or the foot of the
   About page. */

.stay-in-touch {
  text-align: center;
  padding-block: var(--rhythm);
}

.stay-in-touch .form {
  margin-inline: auto;
  justify-content: center;
}

/* ---------- Modal ---------- */
/* One component, two uses (V1_REVISIONS §4.4/§5.2): the Director's
   Statement modal has no trigger logic — it just opens on click — and
   the Stay in touch modal adds engagement-trigger rules on top of the
   same open/close/focus-trap mechanics. Trigger logic lives entirely
   in site.js; nothing here knows or cares why a given modal opened. */

.modal {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: none;
  align-items: center;
  justify-content: center;
  padding: var(--gutter);
}

.modal.is-open {
  display: flex;
}

.modal__backdrop {
  position: absolute;
  inset: 0;
  background: rgba(18, 20, 26, 0.75);
}

.modal__dialog {
  position: relative;
  width: 100%;
  max-width: 560px;
  max-height: 85vh;
  overflow-y: auto;
  background: var(--ground-sky);
  border: 1px solid var(--rule);
  border-radius: 2px;
  padding: clamp(2rem, 5vw, 3rem);
  text-align: left;
}

.modal__dialog::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 1px;
  pointer-events: none;
  background: var(--horizon-gradient);
}

.modal--wide .modal__dialog {
  max-width: 720px;
}

/* V2_REVISIONS §1.2: near-fullscreen, not edge-to-edge. Width is
   capped by both viewport dimensions at once — 90% of viewport width,
   or whatever width holds the player to 85% of viewport height at
   16:9 — so the page stays visibly present around it on short/wide
   screens and tall/narrow ones alike, not just the common case. */
/* V7: the box is the TRAILER's shape, not 16/9. It was 16/9 on the
   assumption every trailer is, and this one is not — it is 1.8522
   (426x230 by Vimeo's own oEmbed), which is theatrical flat, the
   ratio the film was finished in. A 1.85 picture inside a 16/9 box
   gets letterboxed by the player, and the player's bars are WHITE:
   measured on the live site, 13px above the picture and 12px below,
   which on this site reads as a broken white frame rather than as
   letterboxing.
   --trailer-ar comes from config.trailer.ratio via site.js, because
   it describes the video and not the design. The 1.7778 fallback is
   16/9 written as a number, so an unset ratio behaves exactly as this
   rule did before. Both the cap and the shape read it — leaving 16/9
   in the width formula would hold the player smaller than its own box
   allows. */
.modal--trailer .modal__dialog {
  width: min(90vw, calc(85vh * var(--trailer-ar, 1.7778)));
  max-width: none;
  aspect-ratio: var(--trailer-ar, 1.7778);
  padding: 0;
  background: #000;
  border: none;
}

.modal--trailer .modal__dialog::before {
  content: none;
}

.modal--trailer .modal__close {
  color: var(--corona);
  background: rgba(18, 20, 26, 0.5);
  border-radius: 50%;
}

.trailer-modal__player {
  position: relative;
  width: 100%;
  height: 100%;
  /* Clips the iframe's overhang — see the rule below, which is where
     the reasoning lives. */
  overflow: hidden;
}

/* V7b — the 1px white line down each side of the player.
   Separate bug from the letterbox above, and older: it was in the
   first report too, 1px either side under the 13px and 12px bands,
   and survived fixing the ratio.
   It is painted INSIDE the iframe. Reading across the right edge of
   the live player: picture, 1px white, 1px black, page. That black is
   the `background` this rule sets on the iframe element, so the white
   is sitting on top of it — a cross-origin document paints over the
   element's own background, so nothing we set here can ever cover it.
   It is the player document's default white canvas showing at the
   sub-pixel boundary of a fractionally-sized box: the dialog's width
   comes from min(90vw, calc(85vh * ratio)) and lands on fractions
   like 1126.4px.
   Confirmed as the player's and not ours by elimination: with Vimeo
   blocked the same box renders with no white anywhere.
   So the iframe overhangs its container by 1px on every side and the
   container clips it. That costs a pixel of picture per edge, which
   nobody can see, and removes a white line on a dark site, which
   everybody can. */
.trailer-modal__player iframe {
  position: absolute;
  top: -1px;
  left: -1px;
  width: calc(100% + 2px);
  height: calc(100% + 2px);
  border: 0;
  /* Still worth setting, for the frames before the player paints and
     for any future trailer whose ratio drifts from config: an iframe
     with no background of its own falls back to the browser's white
     canvas, and black means a mismatch reads as ordinary letterboxing
     rather than as a broken frame. */
  background: #000;
}

.modal__close {
  position: absolute;
  top: 0.75rem;
  right: 0.75rem;
  width: 2.25rem;
  height: 2.25rem;
  display: flex;
  align-items: center;
  justify-content: center;
  background: transparent;
  border: none;
  color: var(--corona-dim);
  font-size: 1.5rem;
  line-height: 1;
  cursor: pointer;
  transition: color var(--motion-fast);
}

.modal__close:hover,
.modal__close:focus-visible {
  color: var(--corona);
}

.modal__title {
  margin-bottom: 1.25rem;
  padding-right: 2rem;
}

.modal__body p + p {
  margin-top: 1.25rem;
}

.modal__body .stay-in-touch,
.modal__body .form {
  padding-block: 0;
}

@media (prefers-reduced-motion: no-preference) {
  .modal__dialog {
    animation: modal-in 220ms ease both;
  }
}

@keyframes modal-in {
  from {
    opacity: 0;
    transform: translateY(8px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

@media (max-width: 500px) {
  .modal__dialog {
    padding: 1.5rem;
  }
}

/* ---------- About page ---------- */

/* V1_REVISIONS §5.5: this used to sit on the same <section
   class="container sandbox-boilerplate"> element as .container's own
   padding-inline, so the section's box centered correctly but its
   text then got a SECOND, redundant inset from that padding on top of
   it — measured live at 1400px width: the section's own left edge
   lined up exactly with the synopsis text above it, but the actual
   paragraph text sat 80px further right (--gutter's value at that
   width). Every other prose block on this page (.filmmakers, the
   synopsis's .prose div) avoids this by keeping the centering class on
   an INNER element, nested inside a plain .container that supplies
   the responsive edge padding once. Moved this one to match. */
.sandbox-boilerplate {
  max-width: var(--prose-max);
  margin-inline: auto;
}

/* V1_REVISIONS §5.8: credits and technical info grouped into one
   section after the MONTREALKIDS interstitial, with a gap between
   them smaller than the page's own section rhythm — they belong
   together, not spaced like two unrelated sections. */
.credits-technical {
  display: flex;
  flex-direction: column;
  gap: 2rem;
}

.credits {
  display: flex;
  flex-direction: column;
  gap: 1.75rem;
  max-width: 40rem;
  margin-inline: auto;
}

.credits__item {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  text-align: center;
}

.credits__role {
  font-size: 0.85rem;
  color: var(--corona-dim);
}

.credits__names {
  font-size: 1.3rem;
  font-weight: 300;
  color: var(--corona);
}

.technical {
  max-width: 40rem;
  margin-inline: auto;
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.75rem 2.5rem;
  font-size: 0.85rem;
  color: var(--corona-dim);
}

.technical__row {
  display: flex;
  gap: 0.5em;
}

.technical__label {
  color: var(--corona-dim);
}

.technical__value {
  color: var(--corona);
}

.filmmakers {
  display: flex;
  flex-direction: column;
  gap: 3rem;
  max-width: var(--prose-max);
  margin-inline: auto;
}

.filmmaker {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}

.filmmaker__photos {
  display: flex;
  gap: 1rem;
  justify-content: center;
  align-self: center;
}

.filmmaker__photo {
  /* V2_REVISIONS §0.4: was a double crop — Uploadcare returned a 4:5
     scale_crop (600x750) face-aware for that rectangle, then this
     circle took the geometric centre square out of it with no
     face-awareness at all, which is what cut through foreheads when a
     face wasn't perfectly centred in the taller frame. Now requesting
     a square scale_crop directly (600x600/1200x1200 in the HTML) means
     Uploadcare's own face detection runs on the 1:1 target, and
     object-fit:cover here is a no-op crop, not a second one. */
  width: 120px;
  height: 120px;
  border-radius: 50%;
  object-fit: cover;
  align-self: center;
  margin-bottom: 0.5rem;
}

.filmmaker__name {
  font-size: 1.1rem;
  color: var(--corona);
  text-align: center;
}

.filmmaker__role {
  font-size: 0.85rem;
  color: var(--corona-dim);
  text-align: center;
}

.epigraph {
  /* V2_REVISIONS §5.1: considered and rejected — setting this over the
     header image. Sixty-odd words of italic text needs a scrim heavy
     enough to erase the photograph underneath it. Sits below the
     header on solid ground instead, and gains presence there rather
     than just being relocated: larger type and more space around it
     than the 1.2rem/no-extra-padding version that sat over the image
     before. */
  max-width: var(--prose-max);
  margin-inline: auto;
  padding-block: 1.5rem;
  text-align: center;
  font-size: 1.6rem;
  font-weight: 300;
  line-height: 1.6;
  color: var(--corona-dim);
  font-style: italic;
}

.epigraph cite {
  display: block;
  margin-top: 1.25rem;
  font-size: 0.9rem;
  font-style: normal;
}

/* ---------- Press page ---------- */

.reviews.is-hidden,
.releases.is-hidden,
.stills.is-hidden {
  display: none;
}

.reviews__list,
.releases__list {
  display: flex;
  flex-direction: column;
  gap: 2.5rem;
  max-width: var(--prose-max);
  margin-inline: auto;
  margin-top: 2rem;
}

.review {
  text-align: center;
}

.review p {
  font-size: 1.3rem;
  font-weight: 300;
  line-height: 1.5;
  color: var(--corona);
}

.review cite {
  display: block;
  margin-top: 0.75rem;
  font-style: normal;
  font-size: 0.85rem;
  color: var(--corona-dim);
}

.release {
  display: flex;
  justify-content: space-between;
  gap: 1.5rem;
  padding-block: 1.25rem;
}

.release + .release {
  border-top: 1px solid var(--rule);
}

.release__date {
  color: var(--corona-dim);
  font-size: 0.85rem;
  white-space: nowrap;
}

.stills__grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 2rem;
  margin-top: 2rem;
}

.still figure {
  margin: 0;
}

.still img {
  width: 100%;
  aspect-ratio: 3 / 2;
  object-fit: cover;
}

.still figcaption {
  margin-top: 0.5rem;
  font-size: 0.8rem;
  color: var(--corona-dim);
}

.press-contact {
  text-align: center;
}

/* ---------- 404 ---------- */

.notfound__content {
  position: relative;
  z-index: 2;
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  gap: 1.5rem;
  padding-inline: var(--gutter);
}

.notfound__wordmark {
  font-size: 1.1rem;
  font-weight: 400;
  letter-spacing: 0.1em;
  color: var(--corona);
}

.notfound__line {
  font-size: 1.1rem;
  font-weight: 300;
  color: var(--corona-dim);
}

/* ---------- Invitation tool (/invite.html) ----------
   V7. A working page for the directors, not a visitor page: one
   heading, three steps, one button, and a preview of exactly what will
   paste. Built to be used once, quickly, by someone who does not want
   to think about how email works — so it is quiet, centred, and has a
   single obvious thing to press. */
.invite {
  /* Wide enough that the preview frame reaches its full 698px (650 of
     invitation plus 24px padding each side) inside the gutters at
     every desktop width. At 760 the column was 606px wide and the
     preview showed the invitation shrunk to 558 — still correct on the
     clipboard, but not a true picture of what pastes. The text above
     and below keeps its own narrower 34rem measure. */
  max-width: 880px;
  margin: 0 auto;
  padding: var(--rhythm) var(--gutter) calc(var(--rhythm) * 1.5);
  text-align: center;
}

.invite__wordmark {
  font-size: 0.95rem;
  font-weight: 400;
  letter-spacing: 0.32em;
  color: var(--corona-dim);
  margin: 0 0 0.9rem;
}

.invite__label {
  font-size: clamp(1.35rem, 3.2vw, 1.85rem);
  font-weight: 400;
  line-height: 1.3;
  color: var(--corona);
  margin: 0 0 2.2rem;
  /* Evens the two lines out instead of stranding the year alone on the
     second. Browsers without it simply wrap as before. */
  text-wrap: balance;
}

/* The steps are the instructions, so they are left-aligned inside a
   centred column: centred numbered lists are hard to follow. */
.invite__steps {
  /* The site resets every list to list-style: none (top of this
     file), which stripped the 1. 2. 3. from these steps. Numbers are
     the point of a numbered instruction, so they are restored here. */
  list-style: decimal outside;
  max-width: 34rem;
  margin: 0 auto 2.2rem;
  padding-left: 1.4em;
  text-align: left;
  font-size: 1.05rem;
  line-height: 1.6;
  color: var(--corona);
}
.invite__steps li + li { margin-top: 0.55rem; }
.invite__steps strong { font-weight: 600; }

/* Keyboard keys, written out in the third step. */
.invite kbd {
  display: inline-block;
  padding: 0.05em 0.45em;
  border: 1px solid var(--rule);
  border-bottom-width: 2px;
  border-radius: 3px;
  font-family: inherit;
  font-size: 0.92em;
  white-space: nowrap;
  color: var(--corona);
  background: rgba(242, 239, 230, 0.05);
}

/* The one thing to press. Filled from the start rather than on hover,
   unlike the site's other buttons: on this page it is the only action,
   and it should look like one. Same amber, same dark ink as the
   invitation's own TICKETS button, so it reads as part of the set. */
.invite__copy {
  font-size: 1.05rem;
  font-weight: 600;
  letter-spacing: 0.08em;
  padding: 1em 2.6em;
  background: var(--horizon);
  color: var(--ground-deep);
  cursor: pointer;
}
.invite__copy:hover,
.invite__copy:focus-visible {
  background: var(--horizon-hi);
  border-color: var(--horizon-hi);
  color: var(--ground-deep);
}
/* Confirmation is carried by the label ("Copied ✓") and by the status
   line below, not by colour alone. */
.invite__copy.is-copied {
  background: var(--horizon-hi);
  border-color: var(--horizon-hi);
}

/* Reserves its line even when empty, so the preview does not jump down
   the moment a message appears. */
.invite__status {
  min-height: 1.6em;
  margin: 1rem auto 0;
  max-width: 34rem;
  font-size: 1rem;
  color: var(--horizon-hi);
}
.invite__status.is-error { color: var(--corona); }

/* The white frame is a stand-in for the Gmail compose window. Two
   jobs: it marks the edge of the dark invitation, which would
   otherwise vanish into this dark page; and it previews the plain-text
   recap on the white it will actually sit on — that text is styled
   dark-on-white for the email, and would be unreadable on this page's
   own ground. Nothing on the frame is copied: the clipboard takes the
   preview's contents, not the frame. */
.invite__frame {
  display: inline-block;
  width: 100%;
  max-width: calc(650px + 2 * 24px);
  margin: 1.6rem auto 0;
  padding: 24px;
  background: #ffffff;
  border-radius: 4px;
  text-align: left;
  /* An approximation of Gmail's default, for the recap's preview only.
     The recap itself carries no font, so in the real email it takes
     the sender's own. */
  font-family: Arial, Helvetica, sans-serif;
  color: #222222;
}
.invite__frame a { color: #1a0dab; }
.invite__preview img { max-width: 100%; height: auto; }

.invite__note {
  max-width: 34rem;
  margin: 1.4rem auto 0;
  font-size: 0.95rem;
  line-height: 1.55;
  color: var(--corona-dim);
}
.invite__note + .invite__note { margin-top: 0.6rem; }

.invite__empty {
  font-size: 1.1rem;
  font-weight: 300;
  color: var(--corona-dim);
}

@media (max-width: 600px) {
  /* The frame's padding costs real width on a phone; the invitation
     itself still scales to fit either way. */
  .invite__frame { padding: 12px; }
}
.invite__steps li::marker {
  font-weight: 600;
  color: var(--horizon);
}
