/* fonts.css is linked directly in each page's <head> (not @import'd from
   here) — @import forces the browser to fetch this file first, parse it,
   *then* discover fonts.css, adding a full extra round-trip before the
   font files even start downloading. That delay is what let the fallback
   system font flash briefly on every load. */

/* Smooth cross-fade between pages on same-origin navigation (progressive
   enhancement — Chromium today, ignored elsewhere). */
@view-transition {
  navigation: auto;
}

/* ---------- Named view-transition pairs ----------

   A bare @view-transition only cross-fades the whole document. Naming an
   element on both sides of a navigation makes the browser tween that one box
   between its old and new position instead, so it reads as the same object
   moving rather than two pages dissolving.

   A name must be unique within a document at snapshot time, which is why the
   per-event thumbnails get theirs from a data attribute main.js sets on just
   the one being clicked. */

.info-bar__logo {
  /* Identical geometry on every page, so it stays pinned while the rest of
     the document cross-fades under it. */
  view-transition-name: brand-mark;
}

.site-footer {
  view-transition-name: site-footer;
}

/* The thumbnail the visitor clicked (homepage card or archive row) and the
   hero image it lands on — the pair that produces the actual morph. */
[data-vt-media] {
  view-transition-name: event-media;
}

.event-hero__media {
  view-transition-name: event-media;
}

/* Default is a linear cross-fade; ease the geometry tween so the morph settles
   the way the hero-card slide on the homepage does. */
::view-transition-group(event-media) {
  animation-duration: 0.42s;
  animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}

::view-transition-group(brand-mark),
::view-transition-group(site-footer) {
  animation-duration: 0.3s;
}

:root {
  --navy: #18008f;
  --black: #000000;
  --white: #ffffff;
  --card-gray: #ededed;

  --font-mono: "TT Norms Mono", ui-monospace, "SF Mono", Consolas, monospace;

  /* Kept symmetric, as it has always been, but never allowed to fall under the
     notch — which only bites in landscape, where both insets are non-zero. */
  --side-pad: max(
    clamp(1.25rem, 4.2vw, 5rem),
    env(safe-area-inset-left, 0px),
    env(safe-area-inset-right, 0px)
  );

  /* Every page sets viewport-fit=cover, so the document now starts at the
     physical top of the screen rather than below the notch. --safe-top is the
     band that buys — the ticker pads itself by it so the navy runs behind the
     status bar instead of leaving a white gutter above itself, and everything
     that measures the ticker measures --header-strip-h, the bar plus the band.
     The fallback keeps every calc() below valid where env() is unknown. */
  --safe-top: env(safe-area-inset-top, 0px);
  --safe-bottom: env(safe-area-inset-bottom, 0px);
  --header-ticker-h: 2.5rem;
  --header-strip-h: calc(var(--header-ticker-h) + var(--safe-top));
  --footer-h: clamp(4.5rem, 9vh, 6rem);
}

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

html,
body {
  margin: 0;
  padding: 0;
}

body {
  position: relative;
  font-family: var(--font-mono);
  background: var(--white);
  color: var(--black);
  -webkit-font-smoothing: antialiased;
}

/* The strip above the document — the browser's own top chrome is handled by
   <meta name="theme-color">, but iOS also rubber-bands past the top edge and
   paints whatever is up there. Left white, that flashes a bright band above
   the navy ticker on every over-scroll; this fills it with the ticker colour
   instead. Deliberately only the top: the footer is white, so the bottom
   gutter should stay white too. */
body::before {
  content: "";
  position: absolute;
  top: -100vh;
  left: 0;
  right: 0;
  height: 100vh;
  background: var(--navy);
  pointer-events: none;
}

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

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

ul {
  list-style: none;
  margin: 0;
  padding: 0;
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}

/* ---------- Bracket links (shared UI pattern) ---------- */

.bracket-link {
  display: inline-flex;
  align-items: center;
  font-family: var(--font-mono);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.02em;
  white-space: nowrap;
  transition: opacity 0.2s ease;
}

.bracket-link:hover,
.bracket-link:focus-visible {
  opacity: 0.55;
}

/* ---------- Links with nowhere to go ----------

   main.js strips the href off any link whose URL is missing or a bare "#"
   (an unset book URL, the still-unwritten [Merch]), so the click does nothing
   instead of navigating to the top of the page. It keeps its type and size —
   it is the same piece of copy either way — and gives up only the parts that
   promise something will happen. */

.is-inert {
  cursor: default;
}

.is-inert:hover,
.is-inert:focus-visible {
  opacity: 1;
}

/* ---------- Loading screen (homepage) ---------- */

.page-loader {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: clamp(1.25rem, 3.5vh, 2.25rem);
  /* Runs edge to edge, notch band included — the ticker's navy is up there
     already, so the two meet without a seam when this lifts. */
  padding: calc(var(--safe-top) + 1.5rem) 1.5rem calc(var(--safe-bottom) + 1.5rem);
  background: var(--navy);
  transition: opacity 0.6s ease;
}

.page-loader[hidden] {
  display: none;
}

.page-loader.is-done {
  opacity: 0;
  pointer-events: none;
}

/* Landing on index.html#urban opens that category before the screen has
   lifted, which gives the page behind something to scroll — and a scroll
   nobody can see is a scroll they arrive at the bottom of. */
body.is-loading {
  overflow: hidden;
}

.page-loader__logo {
  width: min(46vw, 22rem);
  height: auto;
  /* The wordmark ships as black artwork; this is the same file the header
     uses, turned white, rather than a second copy of it to keep in sync. */
  filter: brightness(0) invert(1);
  animation: loader-pulse 1.6s ease-in-out infinite;
}

.page-loader.is-done .page-loader__logo {
  /* Lets go of the pulse mid-beat and settles into the fade, instead of
     carrying on breathing while the screen disappears around it. */
  animation: none;
  transform: scale(1.06);
  transition: transform 0.6s ease;
}

.page-loader__label {
  margin: 0;
  color: var(--white);
  font-style: italic;
  font-weight: 300;
  font-size: clamp(0.95rem, 1.15vw, 1.25rem);
  letter-spacing: 0.02em;
  opacity: 0.8;
}

@keyframes loader-pulse {
  0%,
  100% {
    opacity: 0.45;
    transform: scale(0.97);
  }
  50% {
    opacity: 1;
    transform: scale(1);
  }
}

/* ================= HEADER ================= */

.site-header {
  flex: 0 0 auto;
  width: 100%;
}

/* Info ticker / marquee strip */

/* Padding rather than a taller bar with centred content: box-sizing is
   border-box everywhere, so the marquee still gets exactly --header-ticker-h
   to sit in and the extra band stays empty navy behind the status bar. */
.ticker {
  background: var(--navy);
  color: var(--white);
  height: var(--header-strip-h);
  padding-top: var(--safe-top);
  overflow: hidden;
  display: flex;
  align-items: center;
}

.ticker__track {
  display: flex;
  width: max-content;
  will-change: transform;
  animation: marquee 32s linear infinite;
}

/* The marquee ran forever on every page, including the whole 4500px of an
   event page scrolled well past the header. A composited animation costs no
   main-thread work, but it does keep waking the compositor every frame and
   holds its layer resident — competing with the scroll for exactly the budget
   the scroll needs. main.js parks the class on the ticker once it leaves the
   viewport, where there is by definition nothing to see. `will-change` goes
   with it: the promise to animate is worth a layer only while it is kept. */
.ticker.is-offscreen .ticker__track {
  animation-play-state: paused;
  will-change: auto;
}

/* And parked again for the length of a category opening or closing, wherever
   it is on screen. Measured, because it was not what I expected: the marquee
   costs a style recalculation per frame while it runs, and a category move is
   the one moment on this page with a transform, a scroll and a panel arriving
   in the same frames. Parking it for those 400ms took the recalculations
   during a close from 78 to 27. Half a second of a strip of text not
   scrolling, while the whole page is moving anyway, is not a thing anyone can
   see.

   `will-change` goes with it, and when it goes is the reason it is repeated
   here rather than left to the .is-offscreen rule above. Opening a category
   scrolls the ticker off the top of the screen, so that rule lands partway
   through the move — and dropping the promise to animate throws the layer
   away, which costs a compositing update and a repaint of whatever was under
   it, somewhere in the middle of the growth. Releasing it here puts that on
   the frame of the tap, which is already doing the layout, and leaves the
   observer with nothing to change when it does fire. */
body.is-hero-moving .ticker__track {
  animation-play-state: paused;
  will-change: auto;
}

@keyframes marquee {
  from {
    transform: translateX(0);
  }
  to {
    transform: translateX(-50%);
  }
}

.ticker__seq {
  display: flex;
  align-items: center;
  flex-shrink: 0;
}

.ticker__item {
  display: flex;
  align-items: center;
  gap: 2.5em;
  padding-inline: 1.25em;
}

.ticker__item span {
  font-size: 0.8rem;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  white-space: nowrap;
}

/* Hides the hardcoded placeholder text baked into the static HTML until
   main.js has set real content — otherwise it flashes on every load. */
.ticker:not(.is-ready) .ticker__item span {
  visibility: hidden;
}

.ticker__mark {
  height: 0.85rem;
  width: auto;
  flex-shrink: 0;
}

/* Set on the <svg>, not on a descendant `path`, because the mark is a
   <use href="#ticker-mark"> and selectors don't reach into its shadow content.
   `fill` is an inherited property, so the cloned paths — which carry no fill
   of their own — pick this up. */
.ticker__mark {
  fill: var(--white);
}

/* Full reduced-motion handling lives at the bottom of this file — the ticker
   used to be the only thing that honoured it. */

/* Info bar: clock / logo / social */

.info-bar {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding: 1.4rem var(--side-pad);
}

.info-bar__clock {
  font-size: 0.8rem;
  font-weight: 400;
  line-height: 1.6;
  letter-spacing: 0.01em;
  white-space: nowrap;
}

/* Not scoped to .info-bar — the mobile menu overlay carries its own copy of
   the clock (see .nav-overlay__head) and needs the same treatment. */
.clock-time {
  text-transform: uppercase;
}

.clock-countdown {
  display: block;
}

/* Hides the hardcoded placeholder text baked into the static HTML until
   main.js has computed the real clock/countdown — otherwise it flashes
   on every load and page navigation. */
[data-clock-time],
[data-clock-countdown] {
  visibility: hidden;
}

[data-clock-time].is-ready,
[data-clock-countdown].is-ready {
  visibility: visible;
}

a.clock-countdown:hover {
  text-decoration: underline;
}

.info-bar__brand {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
}

.info-bar__logo {
  width: clamp(150px, 15vw, 280px);
  height: auto;
}

.info-bar__social {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 0.5rem;
  font-size: 0.8rem;
}

.info-bar__social-row {
  display: flex;
  gap: 1.25rem;
}

/* ---------- Mobile nav: dot button ---------- */

/* Desktop shows the whole nav inline in the info bar, so the button and the
   overlay it drives only exist below the breakpoint (see RESPONSIVE). */
.nav-toggle,
.nav-overlay {
  display: none;
}

.nav-toggle {
  /* The dot cluster is small by design, so the button is sized as a thumb
     target and the cluster is pinned to its trailing edge — that keeps it
     flush with --side-pad while the box itself stays 44px. */
  align-items: center;
  justify-content: flex-end;
  width: 2.75rem;
  height: 2.75rem;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

.nav-toggle__dots {
  position: relative;
  display: block;
  width: var(--dot-box);
  height: var(--dot-box);
}

.nav-toggle__dots span {
  position: absolute;
  top: 50%;
  left: 50%;
  width: var(--dot-d);
  height: var(--dot-d);
  border-radius: 50%;
  background: currentColor;
  /* Centred via a self-relative `translate: -50% -50%` rather than negative
     margins, so the two outer dots only ever need their own offset added on
     top of that, whichever corner they're headed for. */
  translate: -50% -50%;
  transition: translate 0.22s ease;
}

/* Closed: a dotted ⌄ — the two outer dots sit high, the middle one low,
   reading as "tap to expand" the same way a solid chevron would. */
.nav-toggle__dots span:nth-child(1) {
  translate: calc(-50% - var(--dot-gap-x)) calc(-50% - var(--dot-gap-y));
}

.nav-toggle__dots span:nth-child(2) {
  translate: -50% calc(-50% + var(--dot-gap-y));
}

.nav-toggle__dots span:nth-child(3) {
  translate: calc(-50% + var(--dot-gap-x)) calc(-50% - var(--dot-gap-y));
}

/* Open: the same three dots flip into a ⌃ — middle dot rises, outer two
   drop — same "same dots, new positions" move, just now reading "collapse". */
body.is-nav-open .nav-toggle__dots span:nth-child(1) {
  translate: calc(-50% - var(--dot-gap-x)) calc(-50% + var(--dot-gap-y));
}

body.is-nav-open .nav-toggle__dots span:nth-child(2) {
  translate: -50% calc(-50% - var(--dot-gap-y));
}

body.is-nav-open .nav-toggle__dots span:nth-child(3) {
  translate: calc(-50% + var(--dot-gap-x)) calc(-50% + var(--dot-gap-y));
}

/* ---------- Mobile nav: overlay ---------- */

.nav-overlay {
  position: fixed;
  /* Starts under the ticker, which stays navy and keeps running above it — so
     the fill reads as the ticker's colour pouring down the page. */
  inset: var(--header-strip-h) 0 0 0;
  z-index: 40;
  background: var(--navy);
  color: var(--white);
  overflow-y: auto;
  overscroll-behavior: contain;
  clip-path: inset(0 0 100% 0);
  visibility: hidden;
  transition: clip-path 0.55s cubic-bezier(0.76, 0, 0.24, 1), visibility 0s linear 0.55s;
}

body.is-nav-open .nav-overlay {
  clip-path: inset(0 0 0 0);
  visibility: visible;
  transition: clip-path 0.55s cubic-bezier(0.76, 0, 0.24, 1), visibility 0s;
}

/* Sits exactly where the header's own logo and clock sit, in white. Both are
   driven by the same --logo-w / --info-bar-pad variables as the real ones, so
   the wipe uncovering this block looks like those turning white in place. */
.nav-overlay__head {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: var(--info-bar-pad-t) var(--side-pad) 0;
}

.nav-overlay__logo {
  width: var(--logo-w);
  height: auto;
  /* The asset is black artwork; this repaints it white without a second file. */
  filter: brightness(0) invert(1);
}

/* .info-bar__logo claims `brand-mark` for cross-document transitions, and a
   name used twice in one document aborts the whole transition. */
.nav-overlay .info-bar__logo {
  view-transition-name: none;
}

.nav-menu {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: var(--nav-menu-gap) var(--side-pad) 3rem;
  font-size: var(--nav-menu-fs);
  line-height: 1.3;
  text-align: center;
}

/* One blank line between groups, matching the design's spacing. */
.nav-menu__group + .nav-menu__group {
  margin-top: 1.3em;
}

.nav-menu li {
  opacity: 0;
  translate: 0 0.75rem;
  transition: opacity 0.25s ease, translate 0.25s ease;
}

body.is-nav-open .nav-menu li {
  opacity: 1;
  translate: 0 0;
  /* Staggered to start as the fill reaches this part of the screen. */
  transition-delay: calc(0.26s + var(--i) * 0.035s);
}

/* The mock's lines sit one line-height apart, which leaves a tap target only
   as tall as the text. The padding buys back some touch area and the matching
   negative margin keeps it out of the line box, so the rhythm is unaffected. */
.nav-menu a {
  display: inline-block;
  padding: 0.45em 0.7em;
  margin: -0.45em -0.7em;
}

/* ================= FOOTER ================= */

/* The bottom counterpart to the ticker's --safe-top: the document now runs to
   the physical bottom edge, so the footer absorbs the home-indicator band. Its
   white fills it, and the padding keeps [About]/the year off the indicator. */
.site-footer {
  flex: 0 0 auto;
  min-height: calc(var(--footer-h) + var(--safe-bottom));
  padding-bottom: var(--safe-bottom);
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 1.5rem;
  background: var(--white);
  font-size: 0.8rem;
}

.site-footer__year {
  font-weight: 400;
  letter-spacing: 0.02em;
}

/* ================= MAIN PAGE HERO ================= */

.page--main {
  height: 100vh;
  height: 100dvh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.hero {
  flex: 1 1 auto;
  min-height: 0;
  width: 100%;
}

.hero__grid {
  height: 100%;
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  grid-template-rows: minmax(0, 1fr);
  gap: 0;
  padding-inline: var(--side-pad);
}

.hero-card {
  position: relative;
  z-index: 1;
  display: block;
  height: 100%;
  overflow: hidden;
  background: var(--card-gray);
  /* Nothing here is double-tapped to zoom, and saying so is what lets a
     touch become a click immediately instead of after the browser has
     waited to find out whether a second tap is coming. */
  touch-action: manipulation;
}

.hero-card picture {
  display: block;
  height: 100%;
}

.hero-card__media {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform 0.6s ease;
}

.hero-card__media--gif {
  position: absolute;
  inset: 0;
  opacity: 0;
  pointer-events: none;
}

/* Behind a hover query, not because a phone has nothing to hover with but
   because it does the wrong thing with it: a tap leaves the card :hover on
   iOS until something else is touched, so tapping a category started a 0.6s
   scale on the photograph underneath — a second, nested transform animation
   on a full-bleed image, running inside the one that grows the card. */
@media (hover: hover) and (pointer: fine) {
  .hero-card:hover .hero-card__media {
    transform: scale(1.035);
  }

  .hero-card.has-gif:hover .hero-card__media--gif {
    opacity: 1;
  }
}

.hero-card__mark {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  /* Held in a variable rather than set directly, so the open banner on phones
     and tablets can take a share of it without restating all four numbers and
     letting them drift from these. */
  width: var(--mark-w);
  pointer-events: none;
  filter: drop-shadow(0 2px 14px rgba(0, 0, 0, 0.3));
}

.hero-card__mark--urban {
  --mark-w: 79.05%;
}

.hero-card__mark--tssp {
  --mark-w: 63.75%;
}

.hero-card__mark--hst {
  --mark-w: 87%;
}

.hero-card__mark--specials {
  --mark-w: 66%;
}

/* Tapping the open card is what closes a category, and nothing on screen said
   so. Hidden here and switched on under the mobile breakpoint: that is where
   the card opens into a banner with a bottom edge to hang this off, and where
   "tap" is the right word for what closes it — the desktop card is a narrow
   column driven by a pointer. */
.hero-card__back {
  display: none;
  position: absolute;
  left: 50%;
  bottom: clamp(1rem, 3.6vw, 1.75rem);
  transform: translateX(-50%);
  font-family: var(--font-mono);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.02em;
  white-space: nowrap;
  color: var(--white);
  text-shadow: 0 2px 14px rgba(0, 0, 0, 0.45);
  opacity: 0;
  pointer-events: none;
}

/* ---------- Category preview (event grid) ---------- */

button.hero-card {
  width: 100%;
  border: 0;
  margin: 0;
  padding: 0;
  background: none;
  font: inherit;
  color: inherit;
  cursor: pointer;
  appearance: none;
}


.hero-card {
  grid-row: 1;
  transition: transform 0.55s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.3s ease;
}

.hero-card:nth-child(1) {
  grid-column: 1;
}

.hero-card:nth-child(2) {
  grid-column: 2;
}

.hero-card:nth-child(3) {
  grid-column: 3;
}

.hero-card:nth-child(4) {
  grid-column: 4;
}

.hero-card.is-selected {
  transform: translateX(calc(var(--slide-i, 0) * -100%));
  z-index: 3;
}

.hero-card.is-dimmed {
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.3s ease;
}

.events-panel {
  --event-fs: clamp(0.95rem, 1.15vw, 1.4rem);
  grid-column: 2 / 5;
  grid-row: 1;
  z-index: 0;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-template-rows: repeat(2, 1fr);
  column-gap: clamp(1.5rem, 3.6vw, 4.3rem);
  row-gap: clamp(1.25rem, 2.9vw, 3.5rem);
  padding-left: clamp(1.25rem, 2.8vw, 3.4rem);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.25s ease;
}

.hero__grid.is-expanded .events-panel {
  z-index: 2;
  opacity: 1;
  pointer-events: auto;
  transition: opacity 0.4s ease 0.2s;
}

.event-card {
  display: flex;
  flex-direction: column;
  min-height: 0;
  min-width: 0;
  color: inherit;
  text-decoration: none;
}

.event-card__thumb {
  flex: 1 1 auto;
  min-height: 0;
  background: var(--card-gray);
  position: relative;
  overflow: hidden;
}

.event-card__thumb img:not(.media-skeleton__logo) {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.event-card__meta {
  flex: 0 0 auto;
  margin: 0;
  padding-top: 1.2rem;
  font-size: var(--event-fs);
  font-weight: 400;
  line-height: 1.5;
}

.event-card__meta p {
  margin: 0;
}

.event-card__meta p:first-child {
  font-weight: 500;
}

/* An event that has already run stays in its category — it is part of what
   3CNL has done — but stops competing with the ones still open: the photo
   goes half-way to white and the line under it drops to grey.

   Half-transparent over white rather than a white overlay on top of it: the
   two are the same picture, and this one is a property of the image rather
   than a second box to paint and blend for every card in a panel that
   fades in and out with the category. */
.event-card--past .event-card__thumb {
  background: var(--white);
}

.event-card--past .event-card__thumb img:not(.media-skeleton__logo) {
  opacity: 0.5;
}

.event-card--past .event-card__meta {
  color: #999999;
}

/* Pinned to the last cell instead of flowing after the events. A category can
   hold anywhere from zero to five events (main.js caps the slice at five), and
   left to auto-placement this card would land wherever the last event stopped —
   bottom-left with three events, mid-row with four. Grid places
   explicitly-positioned items before auto-placed ones, so the events still fill
   the remaining cells in order around it. */
.event-card--links {
  justify-content: flex-start;
  grid-column: 3;
  grid-row: 2;
}

.event-card__links-title {
  margin: 0 0 2em;
  font-size: var(--event-fs);
  font-weight: 500;
  line-height: 1.5;
}

.event-card__links-list {
  display: flex;
  flex-direction: column;
  gap: 0.2em;
  margin: 0 0 2em;
}

.event-card__links-list .bracket-link,
.event-card__links-archive .bracket-link {
  font-size: var(--event-fs);
  font-weight: 400;
  text-transform: none;
  letter-spacing: 0;
}

.event-card__links-archive {
  margin: 0;
  font-size: var(--event-fs);
  line-height: 1.5;
}

button.bracket-link {
  border: 0;
  background: none;
  padding: 0;
  margin: 0;
  appearance: none;
}

/* The display above is author-origin, so it beats the UA [hidden] rule and a
   JS `el.hidden = true` on a bracket link would otherwise do nothing — same
   gotcha as .event-block's below. */
.bracket-link[hidden] {
  display: none;
}

/* ================= ABOUT PAGE ================= */

.page--about {
  height: 100vh;
  height: 100dvh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.about-main {
  flex: 1 1 auto;
  min-height: 0;
  position: relative;
  overflow: hidden;
}

/* No -webkit-overflow-scrolling: touch. It bought momentum scrolling back when
   iOS didn't give overflow containers any, which has not been true since iOS
   13; what it still does is force the scroller onto its own composited layer,
   which is the opposite of what a phone wants here. Same for .archive-scroll. */
.about-scroll {
  position: absolute;
  inset: 0;
  overflow-y: auto;
  display: flex;
  justify-content: center;
  align-items: flex-start;
  padding: clamp(2.5rem, 6vh, 5rem) var(--side-pad);
}

/* Deliberately just a gradient. This strip used to stack backdrop-filter:
   blur(9px) under a mask-image, which forces the compositor to re-read and
   re-blur the pixels behind it on every scrolled frame — the single most
   expensive thing on the about/event pages. A multi-stop gradient eased toward
   white reads near-identically over body copy at a fraction of the cost. */
.about-scroll__fade,
.event-block__fade {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: clamp(90px, 14vh, 170px);
  opacity: 1;
  pointer-events: none;
  transition: opacity 0.35s ease;
  background: linear-gradient(
    to top,
    rgba(255, 255, 255, 1) 0%,
    rgba(255, 255, 255, 0.98) 22%,
    rgba(255, 255, 255, 0.85) 45%,
    rgba(255, 255, 255, 0.5) 70%,
    rgba(255, 255, 255, 0) 100%
  );
}

.about-copy {
  display: flex;
  flex-direction: column;
  max-width: 41.5ch;
  font-style: italic;
  font-weight: 600;
  text-transform: uppercase;
  font-size: clamp(1.35rem, 2.19vw, 2.625rem);
  line-height: 1;
  letter-spacing: 0;
}

.about-copy p {
  margin: 0 0 1.3em;
}

.about-copy__brand {
  align-self: center;
  margin-top: 0.8em;
}

.about-copy__logo {
  width: clamp(150px, 15vw, 280px);
  height: auto;
}

/* ================= EVENT PAGE ================= */

.page--event {
  height: 100vh;
  height: 100dvh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.event-viewport {
  flex: 1 1 auto;
  min-height: 0;
}

/* `proximity`, not `mandatory`. Each .event-block holds its own
   overflow-y: auto scroller, and with mandatory snapping a scroll that starts
   inside a long block chains to this container and yanks the reader to the
   next screen mid-paragraph. Proximity still snaps when you're close to a
   boundary but lets a deliberate scroll rest where it lands. */
.event-scroll {
  height: 100%;
  overflow-y: auto;
  scroll-snap-type: y proximity;
}

.event-hero,
.event-block {
  scroll-snap-align: start;
  /* `always` forbids passing more than one block per fling, so a reader
     heading for the last section has to flick once per section. */
  scroll-snap-stop: normal;
  height: 100%;
  display: flex;
  flex-direction: column;
  position: relative;
}

/* Author-origin `display` above beats the UA [hidden] rule regardless of
   specificity — same gotcha as .page--admin's, see PROJECT-STATUS.md. */
.event-block[hidden] {
  display: none;
}

/* The sticky post-header permanently overlays the top of every block once
   scrolled past the hero. Reducing the block's own height still left its
   box (and scroll-snap start point) flush with the top of the viewport, so
   tall blocks' centered content rendered underneath the opaque header
   instead of below it — push the centering area down by the header's
   height instead, via padding on the scroll wrapper below. */

.event-hero__row {
  flex: 1 1 auto;
  min-height: 0;
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: clamp(1.5rem, 4vw, 4rem);
  padding: clamp(1.5rem, 4vh, 3rem) var(--side-pad) 0;
}

.event-hero__title {
  margin: 0 0 0.5em;
  font-style: italic;
  font-weight: 600;
  font-size: clamp(1.5rem, 2.19vw, 2.625rem);
  line-height: 1.05;
}

.event-hero__meta {
  margin: 0 0 1.4em;
  font-weight: 500;
  font-size: clamp(0.95rem, 1.25vw, 1.5rem);
  line-height: 1.4;
}

.event-hero__desc {
  margin: 0;
  max-width: 34ch;
  font-weight: 500;
  font-size: clamp(0.95rem, 1.25vw, 1.5rem);
  line-height: 1.4;
}

.event-hero__media {
  position: relative;
  width: clamp(280px, 33.333vw, 640px);
  aspect-ratio: 48 / 31;
  overflow: hidden;
}

.event-hero__media picture,
.event-hero__media img:first-child {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Shown instead of <picture> when an event has no image (or it fails to
   load) — a plain broken-image box with alt text isn't acceptable here. */
.media-skeleton {
  width: 100%;
  height: 100%;
  background: var(--card-gray);
  overflow: hidden;
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
}

.media-skeleton[hidden] {
  display: none;
}

/* The sweep is an oversized band slid across with transform rather than an
   animated background-position: background-position repaints the whole layer
   every frame, and an archive screen can hold ten of these at once. */
.media-skeleton::after {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: -50%;
  width: 200%;
  background: linear-gradient(100deg, transparent 30%, rgba(255, 255, 255, 0.6) 50%, transparent 70%);
  will-change: transform;
  animation: media-skeleton-shimmer 1.6s ease-in-out infinite;
}

.media-skeleton__logo {
  width: 40%;
  opacity: 0.25;
  position: relative;
  z-index: 1;
}

@keyframes media-skeleton-shimmer {
  from {
    transform: translateX(-50%);
  }
  to {
    transform: translateX(50%);
  }
}

.event-hero__cta {
  display: flex;
  flex-direction: row;
  align-items: baseline;
  justify-content: space-between;
  width: clamp(14rem, 24.6vw, 29.5rem);
  justify-self: start;
}

.event-hero__book {
  font-style: italic;
  font-weight: 500;
  text-transform: uppercase;
  font-size: clamp(1.125rem, 1.875vw, 2.25rem);
  white-space: nowrap;
  transition: opacity 0.2s ease;
}

.event-hero__book:hover,
.event-hero__book:focus-visible {
  opacity: 0.6;
}

.event-hero__price {
  font-style: italic;
  font-weight: 300;
  font-size: clamp(1.125rem, 1.875vw, 2.25rem);
  white-space: nowrap;
}

.event-hero__price[hidden] {
  display: none;
}

/* Past events: the CTA is no longer a link (event.js takes the href off and
   gives it a button role), so it needs to be told it is still something to
   click. */
.event-hero__book.is-archive,
.event-postheader__book.is-archive {
  cursor: pointer;
}

.event-blocklinks {
  flex: 0 0 auto;
  display: flex;
  justify-content: space-between;
  gap: 0.75rem 1rem;
  padding: 1rem var(--side-pad) clamp(1.5rem, 4vh, 2.5rem);
  font-style: italic;
  font-weight: 500;
  color: #979797;
  font-size: clamp(0.9rem, 1.5vw, 1.8rem);
}

.event-blocklinks a {
  transition: color 0.2s ease;
}

.event-blocklinks a:hover,
.event-blocklinks a:focus-visible {
  color: var(--black);
}

.event-blocklinks a.is-active {
  font-weight: 600;
  color: var(--black);
}

.event-blocklinks[hidden] {
  display: none;
}

/* Sticky condensed post-header, revealed once the hero scrolls out of view */

.event-postheader {
  position: sticky;
  top: 0;
  z-index: 5;
  background: var(--white);
  opacity: 0;
  transform: translateY(-6px);
  pointer-events: none;
  transition: opacity 0.3s ease, transform 0.3s ease;
}

.event-postheader.is-visible {
  opacity: 1;
  transform: translateY(0);
  pointer-events: auto;
}

.event-postheader__row {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: clamp(1rem, 3vw, 2.5rem);
  padding: 1rem var(--side-pad) 0.75rem;
}

.event-postheader__info {
  font-size: 0.8rem;
  line-height: 1.4;
}

.event-postheader__title {
  margin: 0;
  font-style: italic;
  font-weight: 700;
}

.event-postheader__meta {
  margin: 0;
}

.event-postheader__media {
  position: relative;
  flex: 0 0 auto;
  width: clamp(60px, 6vw, 100px);
  aspect-ratio: 48 / 31;
  overflow: hidden;
}

.event-postheader__media picture,
.event-postheader__media img:first-child {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.event-postheader__book {
  font-style: italic;
  font-weight: 600;
  text-transform: uppercase;
  font-size: 0.85rem;
  white-space: nowrap;
  transition: opacity 0.2s ease;
}

.event-postheader__book:hover,
.event-postheader__book:focus-visible {
  opacity: 0.6;
}

.event-blocklinks--post {
  padding-top: 0.5rem;
  padding-bottom: 0.75rem;
}

/* Per-block scroll area: content centers in the space between the header
   (or header+post-header) and footer, and scrolls internally if it overflows. */

.event-block__scroll {
  /* Named because the section label below pulls itself back against it. */
  --block-gap: clamp(1rem, 3vh, 1.75rem);
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  justify-content: safe center;
  gap: var(--block-gap);
  padding: calc(clamp(1.5rem, 4vh, 3rem) + var(--postheader-h, 0px)) var(--side-pad) clamp(1.5rem, 4vh, 3rem);
}

/* The section's name above its content. Phones only: on desktop a block fills
   the screen on its own and the links overhead carry the same four names, so
   there is no doubt about which one you are looking at. */
.event-block__label {
  display: none;
}

/* Where and when / What's out there */

.event-split {
  display: grid;
  grid-template-columns: 1fr 1fr;
  align-items: center;
  gap: clamp(2rem, 5vw, 5rem);
  width: 100%;
  max-width: 84rem;
}

.event-split__text h2 {
  margin: 0 0 0.7em;
  font-weight: 700;
  font-style: normal;
  font-size: clamp(1.4rem, 2.19vw, 2.625rem);
  line-height: 1.1;
}

.event-split__text p {
  margin: 0 0 1.1em;
  font-size: var(--event-fs, clamp(0.95rem, 1.15vw, 1.4rem));
  line-height: 1.45;
}

.event-split__text p:last-child {
  margin-bottom: 0;
}

.event-split__media {
  aspect-ratio: 48 / 31;
  overflow: hidden;
}

.event-split__media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* The plan */

.event-plan {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  column-gap: clamp(2rem, 6vw, 6rem);
  row-gap: 1.5rem;
  width: 100%;
  max-width: 84rem;
}

.event-plan__date {
  margin: 0 0 0.6em;
  font-weight: 700;
  font-style: normal;
  text-transform: uppercase;
  font-size: clamp(1.4rem, 2.19vw, 2.625rem);
}

.event-plan__media {
  aspect-ratio: 48 / 31;
  overflow: hidden;
  margin-bottom: 0.8em;
}

.event-plan__media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.event-plan__desc {
  margin: 0;
  font-size: var(--event-fs, clamp(0.95rem, 1.15vw, 1.4rem));
  line-height: 1.4;
  /* Each line typed in the admin textarea is its own line here; runs of
     spaces still collapse, so stray indentation doesn't leak through. */
  white-space: pre-line;
}

.event-plan__schedule {
  align-self: flex-start;
  width: 100%;
  max-width: 84rem;
  font-size: var(--event-fs, clamp(0.95rem, 1.15vw, 1.4rem));
  font-weight: 400;
  text-transform: none;
  letter-spacing: 0;
}

/* What's included */

.event-included {
  width: 100%;
  max-width: 84rem;
  font-size: var(--event-fs, clamp(0.95rem, 1.15vw, 1.4rem));
}

.event-included h2 {
  margin: 0 0 1em;
  font-weight: 700;
  font-style: normal;
  font-size: clamp(1.4rem, 2.19vw, 2.625rem);
}

.event-included p {
  margin: 0;
  line-height: 1.4;
}

.event-included__intro {
  margin-bottom: 1.8em;
}

.event-included__tiers {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  column-gap: clamp(2rem, 6vw, 6rem);
  row-gap: 1.5rem;
}

.event-included__tier-name {
  font-weight: 700;
  margin-bottom: 0.5em !important;
}

.event-included__tier p {
  margin-bottom: 1.1em;
}

.event-included__price {
  font-style: italic;
  font-weight: 700;
  margin-top: 0.3em;
}

.event-included__divider {
  margin: 1.8em 0;
  border: none;
  border-top: 1px solid rgba(0, 0, 0, 0.2);
}

.event-included__extra-row {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  column-gap: clamp(2rem, 6vw, 6rem);
  row-gap: 1.5rem;
}

.event-included__extra p {
  margin-bottom: 1.1em;
}

.event-included__transport {
  display: block;
  margin-top: 0.6em;
  font-weight: 400;
  text-transform: none;
  letter-spacing: 0;
  font-size: 1em;
}

/* Full-schedule modal */

.modal {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: clamp(1.5rem, 5vh, 4rem) 1.5rem;
}

.modal[hidden] {
  display: none;
}

.modal__backdrop {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
}

.modal__panel {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  background: var(--white);
  width: min(640px, 100%);
  max-height: 100%;
  overflow-y: auto;
  padding: clamp(1.75rem, 4vw, 3rem);
}

/* In the flow, not absolutely positioned over the panel's top-right corner:
   a heading long enough to wrap ran straight under it on a phone, which is
   not something a fixed offset can be tuned out of. */
.modal__close {
  align-self: flex-end;
  margin-bottom: 0.75rem;
  border: 0;
  background: none;
  font: inherit;
  font-weight: 500;
  /* Both explicit against the UA stylesheet: a <button> takes its colour from
     `buttontext`, which iOS renders as its own system blue, and the rest of
     the site's bracket links are uppercased by a shared rule this one has no
     reason to inherit. */
  color: var(--black);
  text-transform: none;
  cursor: pointer;
  appearance: none;
}

.modal__title {
  margin: 0 0 1.2em;
  font-family: var(--font-mono);
  font-weight: 700;
  font-style: italic;
  /* The floor is what a phone gets, and at 1.3rem it was reading as body
     copy rather than as the heading of the panel. */
  font-size: clamp(1.55rem, 2vw, 1.9rem);
  line-height: 1.15;
}

.modal__text {
  margin: 0;
  font-size: var(--event-fs, clamp(0.95rem, 1.15vw, 1.4rem));
  line-height: 1.6;
}

/* These sit inside a sentence, so they keep the brackets and drop the
   uppercase the shared bracket-link rule would otherwise give them. */
.modal__text .bracket-link {
  text-transform: none;
  letter-spacing: 0;
  font-size: 1em;
  font-weight: 600;
  white-space: normal;
}

.modal__day {
  margin-bottom: 1.6em;
  font-size: 0.95rem;
  line-height: 1.6;
}

.modal__day p {
  margin: 0;
}

.modal__day-title {
  font-weight: 600;
  margin-bottom: 0.3em !important;
}

/* ================= ARCHIVE PAGE ================= */

.page--archive {
  height: 100vh;
  height: 100dvh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.archive-main {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  padding-inline: var(--side-pad);
}

.archive-head {
  flex: 0 0 auto;
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 1rem 3rem;
  padding-block: clamp(1.5rem, 4vh, 3rem) clamp(1.25rem, 3vh, 2rem);
}

.archive-title {
  margin: 0;
  font-style: italic;
  font-weight: 700;
  text-transform: uppercase;
  font-size: clamp(1.75rem, 2.19vw, 2.625rem);
  line-height: 1;
  letter-spacing: 0.01em;
}

.archive-filters {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem 2.5rem;
  font-size: clamp(0.85rem, 0.9vw, 1.1rem);
}

.filter-group {
  display: flex;
  align-items: center;
  gap: 0.4em;
  white-space: nowrap;
}

.filter-label {
  opacity: 0.55;
}

.filter-option {
  border: 0;
  background: none;
  padding: 0;
  margin: 0;
  font: inherit;
  font-weight: 400;
  color: inherit;
  cursor: pointer;
  appearance: none;
  transition: opacity 0.2s ease;
}

.filter-option:hover,
.filter-option:focus-visible {
  opacity: 0.55;
}

.filter-option.is-active {
  font-weight: 600;
  opacity: 1;
}

.filter-sep {
  opacity: 0.35;
}

.archive-scroll {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
}

.archive-list {
  --row-fs: clamp(0.95rem, 1.15vw, 1.4rem);
  display: flex;
  flex-direction: column;
  gap: clamp(1.25rem, 2vh, 1.9rem);
  padding-bottom: clamp(1.5rem, 4vh, 3rem);
}

.archive-row {
  display: grid;
  grid-template-columns: 8.5% 17% 17% 14% 1fr;
  column-gap: clamp(1rem, 2.2vw, 2.6rem);
  align-items: center;
  color: inherit;
  text-decoration: none;
}

.archive-row__thumb {
  width: 100%;
  aspect-ratio: 48 / 31;
  background: var(--card-gray);
  position: relative;
  overflow: hidden;
}

.archive-row__thumb img:not(.media-skeleton__logo) {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.archive-row__cell {
  font-size: var(--row-fs);
  line-height: 1.4;
  min-width: 0;
}

.archive-empty {
  font-size: var(--row-fs);
  opacity: 0.6;
  padding-block: 2rem;
}

/* ================= RESPONSIVE ================= */

@media (max-width: 900px) {
  /* ---------- Mobile header ----------

     Every size here is the Figma mock's measurement over its 1180px frame
     width, expressed as vw, so the header keeps the designed proportions at
     any phone size. The clamps are readability floors/ceilings for the ends
     of the range the mock doesn't cover.

     The one number everything else hangs off is --logo-w: the clock sits in a
     box exactly that wide (mock: both are 620 of 1180), and the type scale is
     derived from it rather than set independently, so the two can't drift. */
  :root {
    --header-ticker-h: clamp(1.75rem, 8.2vw, 2.25rem);
    --logo-w: clamp(12rem, 52.5vw, 16rem);
    --logo-h: calc(var(--logo-w) / 8.474); /* 3cnl-logo.svg is 322x38 */

    --hdr-fs: calc(var(--logo-w) / 20); /* mock: 620 wide / 31px */
    /* The mock's ratio to the clock is 18/31, which lands on ~6px at phone
       width — proportionally right, too small to read. This keeps the strip
       clearly quieter than the clock without going illegible. */
    --ticker-fs: max(0.5rem, calc(var(--hdr-fs) * 0.6));
    /* Mock: 38px / 31px, then 25% up — the menu is the one screen with nothing
       else on it, and the links were reading smaller than they need to. The
       clock above them stays at its mock size. */
    --nav-menu-fs: calc(var(--hdr-fs) * 1.5375);

    --info-bar-pad-t: 6.2vw;
    --info-bar-pad-b: 5.7vw;
    --info-bar-gap: 3.4vw;
    --nav-menu-gap: 7vw;

    /* The box the burger used to occupy, up 50% — the dots read small at that
       original footprint. The dots themselves only follow it up 20%: growing
       both at the same rate would just reproduce the old cluster at a bigger
       scale, so most of the extra room goes to spacing between them instead. */
    --dot-box: calc(clamp(1.05rem, 4.83vw, 1.5rem) * 1.5);
    --dot-d: max(4.8px, calc(var(--dot-box) * 0.192));
    --dot-gap-x: calc(var(--dot-box) * 0.24);
    --dot-gap-y: calc(var(--dot-box) * 0.12);
  }

  .ticker__item span {
    font-size: var(--ticker-fs);
  }

  .ticker__mark {
    height: calc(var(--ticker-fs) * 1.06);
  }

  .info-bar {
    flex-direction: column;
    align-items: center;
    gap: var(--info-bar-gap);
    padding: var(--info-bar-pad-t) var(--side-pad) var(--info-bar-pad-b);
  }

  .info-bar__brand {
    position: static;
    transform: none;
    order: -1;
  }

  .info-bar__logo {
    width: var(--logo-w);
  }

  .info-bar__clock {
    width: var(--logo-w);
    max-width: 100%;
    font-size: var(--hdr-fs);
    text-align: center;
  }

  /* Instagram/Strava/Merch move into the menu at this width. */
  .info-bar__social {
    display: none;
  }

  .nav-toggle {
    display: flex;
    position: absolute;
    right: var(--side-pad);
    /* Centred on the logo, as in the mock. */
    top: calc(var(--info-bar-pad-t) + var(--logo-h) / 2);
    transform: translateY(-50%);
    /* Black on the white page, white on the fill — switched the instant the
       edge sweeps past the button rather than faded, since a white-to-black
       fade spends its middle unreadable against both. The delays are when the
       eased wipe reaches this height, opening and closing. */
    z-index: 60;
    transition: color 0s linear 0.39s;
  }

  body.is-nav-open .nav-toggle {
    color: var(--white);
    transition: color 0s linear 0.16s;
  }

  /* Fixed while the fill is up (and while it retreats), or the button scrolls
     away with the header on a page that was already scrolled when the menu was
     opened. Same offsets as the absolute position plus the ticker it now
     clears, so nothing moves at the top of the page. */
  body.is-nav-open .nav-toggle,
  body.is-nav-closing .nav-toggle {
    position: fixed;
    top: calc(var(--header-strip-h) + var(--info-bar-pad-t) + var(--logo-h) / 2);
  }

  .nav-overlay {
    display: block;
  }

  .nav-overlay .info-bar__clock {
    margin-top: var(--info-bar-gap);
  }

  .nav-menu a {
    /* .bracket-link uppercases; the menu is set in title case in the mock. */
    text-transform: none;
  }

  /* The overlay is fixed to the viewport, so the ticker has to be pinned too
     or its bottom edge stops lining up with the fill's top edge whenever the
     menu is opened from a scrolled position. `is-nav-closing` holds that for
     the length of the close, so the ticker doesn't drop away the moment the
     fill starts retreating. */
  body.is-nav-open,
  body.is-nav-closing {
    overflow: hidden;
  }

  body.is-nav-open .ticker,
  body.is-nav-closing .ticker {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    z-index: 50;
  }

  /* Pinning the ticker takes it out of flow, which would pull the logo and
     clock up by its height at the exact moment the wipe starts — while
     they're still uncovered. This holds their place. */
  body.is-nav-open .site-header,
  body.is-nav-closing .site-header {
    padding-top: var(--header-strip-h);
  }

  .page--main {
    height: auto;
    min-height: 100vh;
    min-height: 100dvh;
    overflow: visible;
  }

  .hero {
    min-height: 70vh;
  }

  /* Collapsed, the homepage is exactly one screenful — header, the four
     category cards, footer — so there is nothing below the fold to scroll to,
     and letting it scroll anyway just jiggles the page under the thumb.
     Expanding a category appends a list that genuinely has to scroll, which is
     why this is scoped by :has() instead of turned off for the breakpoint. The
     hero gives up its 70vh floor here too: with the page pinned to the
     viewport, it should take the room that's left rather than ask for more. */
  .page--main:not(:has(.hero__grid.is-expanded)) {
    height: 100vh;
    height: 100dvh;
    overflow: hidden;
  }

  .page--main:not(:has(.hero__grid.is-expanded)) .hero {
    min-height: 0;
  }

  /* Open, the page is taller than the screen by definition — the events see to
     that — so this floor decides nothing, and being written in dvh made it
     expensive for what it decides: dvh is re-resolved every time iOS shows or
     hides its URL bar, the URL bar moves because the page is scrolling, and
     the page is scrolling on exactly the frames the card is growing. Back to
     vh, which is the large viewport and never moves. Same selector the rule
     above already forces the engine to track, so it is not a second one. */
  .page--main:has(.hero__grid.is-expanded) {
    min-height: 100vh;
  }

  .hero__grid {
    grid-template-columns: repeat(4, 1fr);
    padding-inline: 0;
  }

  .hero__grid.is-expanded {
    display: flex;
    flex-direction: column;
    padding: 1.5rem 1.25rem;
    gap: 1.5rem;
  }

  .hero__grid.is-expanded .hero-card:not(.is-selected) {
    display: none;
  }

  /* ---------- How long any of this takes ----------

     The move used to run on the desktop slide's timing: cubic-bezier(0.22, 1,
     0.36, 1) over 0.55s. That is an ease-out steep enough to cover 97% of the
     growth in the first 280ms and then spend another 270ms creeping through
     the last 3% — traced, not guessed — and it was chained with a settle doing
     the same thing, so something that visually arrived in a third of a second
     carried on twitching until nearly a second. A move still going long after
     it looks finished is what reads as lag, which is what this felt like on
     both a phone and a Mac despite neither being anywhere near a limit: no
     main-thread blocking, 0.02ms to force style and layout mid-move, and
     quadrupling the mark's blur barely registering.

     So: shorter, and on a curve that lands instead of drifting in. The pair
     stays split — transform on the new timing, opacity left where it was —
     because collapsing them to one value would quietly retime every fade on
     the card as well. Desktop keeps the old slide; nothing was wrong with it,
     and there it is a single motion with nothing to chain onto. */
  .hero-card {
    transition-duration: 0.34s, 0.3s;
    transition-timing-function: cubic-bezier(0.33, 1, 0.68, 1), ease;
  }

  /* Those percentages were chosen against the card on the homepage, which is
     a narrow portrait tile. Opening a category here turns it into a banner
     that is much wider without being any taller, and the same share of that
     wider box is a mark that swallows the photograph and runs into the label
     below it. Half puts the proportion back, and taking it off --mark-w keeps
     the four marks the same sizes relative to each other. */
  .hero__grid.is-expanded .hero-card.is-selected .hero-card__mark {
    width: calc(var(--mark-w) / 2);
  }

  /* A shade over the clock's size rather than exactly it: this one has to be
     read off a photograph, and it is the only thing on screen saying how to
     get back out of a category. */
  .hero-card__back {
    display: block;
    font-size: calc(var(--hdr-fs) * 1.2);
  }

  .hero__grid.is-expanded .hero-card.is-selected .hero-card__back {
    opacity: 1;
    transition: opacity 0.3s ease;
  }

  /* `relative`, not `static`: the card's category mark is absolutely
     positioned against it, and taking the card out of the positioned chain
     dropped the mark onto the page instead, floating over the event list. */
  .hero__grid.is-expanded .hero-card.is-selected {
    position: relative;
    transform: none;
    height: 42vh;
  }

  /* Not scoped to .is-expanded, and that is the whole point. Closing a
     category only fades the panel out — main.js leaves the rendered events in
     it — so a collapsed panel is still laid out, and under the desktop rules
     it was still three columns of ~107px at phone width. The nowrap [City]
     links in .event-card--links don't fit that, so they ran ~66px past the
     right edge. Invisible, but it widened the document all the same, and iOS
     answered by scaling the page down to fit: coming back from a category,
     the homepage visibly shrank away from the right edge. */
  .events-panel {
    grid-template-columns: repeat(2, 1fr);
    grid-template-rows: auto;
    padding-left: 0;
    row-gap: 1.5rem;
  }

  /* The desktop panel pins this to cell 3/2. Here the panel is two columns of
     auto-height rows, so that pin would invent a third column — let it flow
     last instead, which is where it belongs in a stacked layout anyway. */
  .event-card--links {
    grid-column: auto;
    grid-row: auto;
  }

  .hero__grid.is-expanded .events-panel {
    position: static;
    grid-column: auto;
    grid-row: auto;
  }

  /* Belt and braces for a state nobody can see. The rules above make the
     collapsed panel fit at the widths we know about; this makes it structurally
     unable to widen the document at any width, whatever lands in it later. */
  .hero__grid:not(.is-expanded) .events-panel {
    overflow: hidden;
  }

  .events-panel .event-card__thumb {
    flex: none;
    aspect-ratio: 4 / 3;
  }

  .page--archive {
    height: auto;
    min-height: 100vh;
    min-height: 100dvh;
    overflow: visible;
  }

  .archive-row {
    grid-template-columns: 22vw 1fr;
    grid-template-rows: auto auto;
    row-gap: 0.5rem;
    column-gap: 1rem;
  }

  .archive-row__thumb {
    grid-row: 1 / 4;
  }

  .archive-row__category {
    opacity: 0.6;
  }

  /* Event page: give up the fixed-viewport/scroll-snap "one block per
     screen" layout in favor of a normal stacked page, same trade-off as
     .page--main / .page--archive above. */
  .page--event {
    height: auto;
    min-height: 100vh;
    min-height: 100dvh;
    overflow: visible;
  }

  .event-scroll {
    height: auto;
    overflow-y: visible;
    scroll-snap-type: none;
  }

  .event-hero,
  .event-block {
    height: auto;
    scroll-snap-align: none;
  }

  /* The base padding-top reserves the bar's height on top of its own inset,
     which on a phone is paid at the fold — where the bar is not even up yet —
     and read as a hole between the hero and the first section. The bar is
     fixed here, so content is free to pass under it, and the one place that
     has to clear it is an anchor jump, which .event-block's scroll-margin
     already handles. */
  .event-block__scroll {
    overflow-y: visible;
    padding-top: clamp(1.25rem, 3vh, 2rem);
  }

  /* ---------- Event hero ----------

     Figma frame 34:66, measured over its 1180px width and expressed as vw,
     the same convention as the mobile header above.

     That frame is one phone screen: centred title, date/place, image,
     description, price row — and nothing else. So the piece sized to the
     viewport is the row, not the whole .event-hero section, which leaves the
     section links directly under the fold as the first thing a scroll turns
     up. --header-h is measured by event.js; the ticker and info bar both
     reflow with the viewport, so it can't be derived here. */
  .event-hero__row {
    display: flex;
    flex-direction: column;
    align-items: center;
    /* A phone screen is taller than the mock's frame, and centring hands the
       difference the image hasn't taken to the margins above and below
       rather than pooling it all under the price row. */
    justify-content: center;
    text-align: center;
    gap: 0;
    /* svh, not dvh, and this is the one place on the site where the difference
       is worth spelling out. dvh tracks the viewport as iOS collapses its
       toolbar, so the row — the only element here actually sized to the
       viewport — resized mid-scroll and reflowed the whole 4500px below it,
       every time the toolbar moved. svh is the height with the toolbar shown,
       and it never changes, so there is nothing to reflow.

       It costs nothing visually, because svh and dvh are the same number in
       the state the hero is actually looked at: at the top of the page, where
       the toolbar is up. Scroll and the viewport grows past the row's floor —
       but by then the row is behind you, and coming back to the top brings the
       toolbar back with it. The fallback stays first, per the ordering bug in
       PERFORMANCE.md §2; svh shipped alongside dvh, so it needs no other. */
    min-height: calc(100vh - var(--header-h, 0px));
    min-height: calc(100svh - var(--header-h, 0px));
    /* The bottom inset for the same reason .site-footer takes it: the page
       runs under the home indicator now, and the fold is where the price row
       sits — without this it's the one line the indicator lands on. */
    padding: 3.8vw var(--side-pad) calc(11.9vw + var(--safe-bottom));
  }

  /* Lifts title/date/description out of their wrapper so the image can sit
     between the date and the description as the mock has it, without
     breaking up the block the desktop three-column row is built from. */
  .event-hero__info {
    display: contents;
  }

  .event-hero__title {
    order: 1;
    margin: 0 0 2.2vw;
    font-weight: 700;
    font-size: clamp(1.25rem, 5.42vw, 1.75rem);
  }

  .event-hero__meta {
    order: 2;
    margin: 0 0 5.3vw;
    font-size: clamp(0.9rem, 4.07vw, 1.25rem);
    line-height: 1.3;
  }

  /* flex-basis is the mock's 675/1180; growing past it means a tall phone
     spends its extra height on the image rather than on dead space, and
     shrinking below it keeps a short one in one piece. The floor and ceiling
     are where the crop stops reading as the designed landscape one — past
     the ceiling the leftover goes to the row's margins instead. */
  .event-hero__media {
    order: 3;
    width: 100%;
    aspect-ratio: auto;
    flex: 1 1 57.3vw;
    min-height: 42vw;
    max-height: 78vw;
    margin-bottom: 5.3vw;
  }

  .event-hero__desc {
    order: 4;
    margin: 0 0 5.9vw;
    max-width: none;
    font-size: clamp(0.9rem, 4.07vw, 1.25rem);
    line-height: 1.5;
  }

  .event-hero__cta {
    order: 5;
    flex-direction: row;
    align-items: baseline;
    justify-content: center;
    gap: 3.4vw;
    width: auto;
  }

  .event-hero__book,
  .event-hero__price {
    font-size: clamp(1.25rem, 5.42vw, 1.75rem);
  }

  /* ---------- Fixed section bar ----------

     The condensed post-header repeated the hero — same name, date, thumb and
     CTA, one screen apart — and on a phone it never even appeared: the
     observer that reveals it watches .event-scroll, which gives up being a
     scroll container at this width, so the class never lands. Its box landed
     all the same: ~150px of transparent flow wedged between the section links
     and the first block. That was the gap.

     So here the element keeps only what the hero can't hand over once it has
     scrolled away — the section links and the book CTA — and takes no room in
     the page at all: it is fixed over the top, and the reveal it was written
     for (which the observer above now actually performs) brings it in the
     moment the hero has gone past. */
  .event-postheader {
    display: flex;
    align-items: center;
    position: fixed;
    left: 0;
    right: 0;
    /* Every page sets viewport-fit=cover, so `top: 0` is the physical top of
       the screen: the white runs up behind the status bar the way the ticker's
       navy does, and the inset it spans becomes padding so the links stay
       clear of it. */
    padding-top: var(--safe-top);
    border-bottom: 1px solid rgba(0, 0, 0, 0.2);
  }

  /* Author-origin `display` beats the UA [hidden] rule regardless of
     specificity — same gotcha as .event-block's above. event.js hides the bar
     outright when an event has no sections to link to. */
  .event-postheader[hidden] {
    display: none;
  }

  /* The strip's first name is cut mid-letter the moment it scrolls. At rest
     the band lies over the side padding and hides nothing. */
  .event-postheader::before {
    content: "";
    position: absolute;
    left: 0;
    top: 0;
    bottom: 0;
    width: var(--side-pad);
    background: linear-gradient(to left, rgba(255, 255, 255, 0), var(--white));
    pointer-events: none;
  }

  .event-postheader__row {
    position: relative;
    flex: 0 0 auto;
    padding: 0 var(--side-pad) 0 1.4rem;
  }

  /* The links scroll under the CTA, and a name cut off mid-letter against it
     reads as a rendering fault rather than as more to come. */
  .event-postheader__row::before {
    content: "";
    position: absolute;
    right: 100%;
    top: 0;
    bottom: 0;
    width: 1.4rem;
    background: linear-gradient(to right, rgba(255, 255, 255, 0), var(--white));
    pointer-events: none;
  }

  /* One screen up, in full. */
  .event-postheader__info,
  .event-postheader__media {
    display: none;
  }

  .event-postheader__book {
    font-size: calc(var(--hdr-fs) * 1.2);
  }

  /* The hero's copy of the links sits below its fold, so it is only ever seen
     in passing on the way to the bar's — which says the same thing, in the
     same words, and stays. */
  .event-hero .event-blocklinks {
    display: none;
  }

  /* One line scrolled sideways rather than the wrapped block this was: four
     section names do not fit a phone at any size worth reading (they run 432px
     of text at 12px), and two stacked rows is a lot of screen to hold open
     permanently. Sized and inset off the header's own scale, so the bar reads
     as the strip under it rather than as page content. */
  .event-blocklinks--post {
    order: -1;
    flex: 1 1 auto;
    min-width: 0;
    flex-wrap: nowrap;
    justify-content: flex-start;
    gap: 1.4em;
    overflow-x: auto;
    overscroll-behavior-x: contain;
    scrollbar-width: none;
    padding: 0.85rem 0 0.85rem var(--side-pad);
    font-size: calc(var(--hdr-fs) * 1.2);
  }

  .event-blocklinks--post::-webkit-scrollbar {
    display: none;
  }

  .event-blocklinks--post a {
    white-space: nowrap;
  }

  /* Without this the bar lands on top of whatever a link jumped to.
     --postheader-h is measured by event.js onto .event-scroll. */
  .event-block {
    scroll-margin-top: var(--postheader-h, 0px);
  }

  /* The section's name in the links' italic, greyed and set under the heading
     it introduces, pulled back against the flow gap so the two read as one
     unit. A block used to open straight into its own h2, which says what the
     section is about but never which section it is. */
  .event-block__label {
    display: block;
    width: 100%;
    max-width: 84rem;
    margin: 0 0 calc(0.5em - var(--block-gap));
    color: #979797;
    font-style: italic;
    font-weight: 500;
    font-size: clamp(1rem, 4.6vw, 1.25rem);
    line-height: 1.2;
  }

  /* One step off the desktop's bold: at phone sizes the headings sit close
     enough to the body copy to shout without it, and the greyed labels above
     them are two steps lighter again. */
  .event-split__text h2,
  .event-included h2,
  .event-plan__date {
    font-weight: 600;
  }

  /* The label above it already says this, in the same words. */
  .event-included > h2 {
    display: none;
  }

  .event-split,
  .event-plan,
  .event-included__tiers,
  .event-included__extra-row {
    grid-template-columns: 1fr;
  }
}

@media (max-width: 560px) {
  /* Two flexible rows rather than a fixed height: the page is pinned to the
     viewport when collapsed, so the 2x2 has to take the space the header and
     footer leave rather than name a size of its own and overflow it. */
  .hero__grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    grid-template-rows: repeat(2, minmax(0, 1fr));
  }

  /* Everything above places the four cards side by side on a single row, and
     the panel across columns 2-4. Left alone, those pins keep inventing
     columns 3 and 4 here, and an invented track is `auto` — it sizes to the
     card image and swallows the row, leaving the two real 1fr tracks at ~0px.
     So the second row has to be spelled out: drop cards 3 and 4 onto it, and
     pull the panel's span back to the columns that now exist. */
  .hero-card:nth-child(3) {
    grid-column: 1;
    grid-row: 2;
  }

  .hero-card:nth-child(4) {
    grid-column: 2;
    grid-row: 2;
  }

  .events-panel {
    grid-column: 1 / 3;
    grid-row: 1 / 3;
  }

  /* The [City] links are nowrap and wider than half a phone screen, so the card
     holding them takes the whole row instead of sharing it with an event. */
  .hero__grid.is-expanded .event-card--links {
    grid-column: 1 / -1;
  }

  /* ---------- Opening a category ----------

     A cell of a 2x2 is exactly half the block in both directions, so the open
     card — the whole block — is the collapsed card at 2x about the corner it
     sits in. That equality was drawn for a growth that no longer happens, and
     it is worth keeping anyway: it means the card the reader tapped and the
     card they end up looking at are the same box at the same aspect, so the
     photograph inside is framed the same way before and after. A card that
     changed shape on the way would crop differently, which a cut shows more
     plainly than a move ever did.

     It holds only while both states are the same grid, which is why this one
     stays a grid with the same two columns instead of becoming the flex column
     it is on tablets above. */
  .hero__grid.is-expanded {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    /* --hero-block is the collapsed grid's measured height, set by main.js.
       The rows have to hold it once the page starts scrolling, or the card
       would open at a different size from the four it replaces. */
    grid-template-rows: repeat(2, calc(var(--hero-block, 60vh) / 2)) auto;
    padding: 0;
    gap: 0;
  }

  /* Out of the layout, as the tablet rules already do it. They used to keep
     their cells so the growing card had a homepage to wipe over; with nothing
     growing they are three full-bleed photographs held in the layout of a page
     that is covering them, on the device with the least room to hold them. */
  .hero__grid.is-expanded .hero-card:not(.is-selected) {
    display: none;
  }

  /* ---------- Nothing here animates ----------

     Opening a category on a phone is a state change, not a move. The card that
     was tapped becomes the whole block, the other three go, the events arrive
     underneath and the page lands on them — all in the frame of the tap.

     It was a move for four passes: the card grew out of its quadrant, the mark
     dropped into the middle of what stayed on screen, the page scrolled up to
     meet the list. Every pass made it cheaper and none of them made it smooth,
     because a phone drawing a full-bleed photograph across the whole screen at
     sixty frames a second is the part that was never going to work. What is
     left is the geometry all that arrived at, with the transitions taken off —
     so the open state is identical, it simply does not take 500ms to reach.

     Concretely, gone from this breakpoint: the scale transition on the card and
     the two full-screen composited layers it needed (the photograph, and the
     mark under its 14px drop-shadow), the mark's own drop, and the fades that
     were timed around all of it. The rules below set positions, not journeys.

     The desktop slide above is untouched — it is a narrow column moving
     sideways with room to do it, and none of this was ever its problem. */
  .hero-card {
    transition: none;
  }

  /* The three that aren't open go at once. They used to be held visible for
     0.38s so the growing card had a homepage to grow over; there is no growth
     to cover them any more, and holding them would just be a flash of the old
     state under the new one.

     The card being replaced when a category is swapped from inside an open one
     goes with them, and used to be the exception: it held the block and
     cross-faded, which is fading a full-bleed photograph across the whole
     screen — the growth's work under a different name. There is no exception
     now, and no .is-outgoing either; swapping is the same cut as opening.

     This has to name .is-dimmed rather than lean on the blanket
     `transition: none` above: that rule carries two classes and outranks it. */
  .hero__grid.is-expanded .hero-card.is-dimmed {
    transition: none;
  }

  .hero__grid.is-expanded .hero-card.is-selected {
    grid-column: 1 / -1;
    grid-row: 1 / 3;
    height: 100%;
  }

  .hero__grid.is-expanded .events-panel {
    grid-column: 1 / -1;
    grid-row: 3;
    padding: 1.5rem 1.25rem 2rem;
    /* Six cards and six photographs. Fading a subtree that size needs a layer
       for the whole of it, which on this device is the cost the rest of this
       block exists to avoid — and there is nothing to fade in from, since the
       list arrives with the page that scrolls to it. */
    transition: none;
  }

  /* The label arrives with the card it labels. It used to wait for the card to
     finish growing, so that it did not sit over something that still looked
     like the homepage; the card no longer takes any time to arrive, so there
     is nothing left to wait for. It carries a text-shadow, which is not free
     to fade. */
  .hero__grid.is-expanded .hero-card.is-selected .hero-card__back {
    transition: none;
  }

  /* The tablet banner needs the mark halved because it is a much wider box
     than the tile the percentage was drawn for. This one isn't: the open card
     here is the tile at 2x in both directions, so the same share of it is the
     same mark at the same proportion — which is also what lets it grow out of
     the tile as one uniform scale. Halving it would break both.

     It does sit lower than centre, though. Once the events are up under it the
     card's top is off the top of the screen and only its bottom half is left,
     so a mark centred in the card would sit high in what can still be seen of
     it. This puts it in the middle of that band: the scroll leaves the card's
     bottom edge at half the screen, so the middle of what is visible is 25dvh
     down, which is half the card's height above its bottom edge minus that.

     Set on the transform it is already centred with rather than on `top`, so
     it is a position the compositor is given rather than a second layout of
     the mark. It does not travel there — the card opens and the page scrolls
     in one frame, and this is simply where the mark is once both have
     happened.

     --mark-drop is that distance, worked out once in main.js off the same two
     numbers the scroll is. It used to be spelled out here as
     `--hero-block / 2 - 25dvh`, which is the same arithmetic and was a bad
     idea: dvh is re-resolved every time iOS shows or hides its URL bar, and
     the URL bar moves because the page is scrolling. As a length it is
     resolved once. */
  .hero__grid.is-expanded .hero-card.is-selected .hero-card__mark {
    width: var(--mark-w);
    transform: translate(-50%, calc(-50% + var(--mark-drop, 0px)));
    transition: none;
  }

  .archive-head {
    flex-direction: column;
    align-items: flex-start;
  }
}

/* ================= REDUCED MOTION ================= */

/* Everything that moves on its own, or moves far enough to cause discomfort,
   is stripped here. State changes still happen — they just happen instantly
   (or fade, which is the one transform-free cue that stays comfortable), so
   selecting a category or scrolling past the hero still reads as a change. */
@media (prefers-reduced-motion: reduce) {
  .ticker__track,
  .media-skeleton::after,
  .page-loader__logo {
    animation: none;
  }

  /* The loading screen still fades out — a full-screen colour vanishing
     between one frame and the next is its own kind of jolt — but the logo
     stops breathing and stops growing on the way out. */
  .page-loader.is-done .page-loader__logo {
    transform: none;
  }

  /* Without the sweep the band would sit frozen mid-skeleton. */
  .media-skeleton::after {
    display: none;
  }

  /* The menu still opens and closes — the fill just arrives at once instead of
     wiping down the screen, and the links don't rise into place. */
  /* The button's colour delay is timed to when the fill sweeps past it; with
     the fill arriving at once it has nothing to wait for. */
  .nav-overlay,
  body.is-nav-open .nav-overlay,
  .nav-menu li,
  body.is-nav-open .nav-menu li,
  .nav-toggle,
  body.is-nav-open .nav-toggle,
  .nav-toggle__dots span,
  body.is-nav-open .nav-toggle__dots span {
    transition-duration: 0.01ms;
    transition-delay: 0s;
  }

  .nav-menu li {
    translate: none;
  }

  .hero-card,
  .hero-card__media,
  .event-postheader,
  .about-scroll__fade,
  .event-block__fade,
  .events-panel,
  .hero__grid.is-expanded .events-panel {
    transition-duration: 0.01ms;
  }

  /* The panel's fade is held back on desktop until the card has slid out of
     the way; with the slide gone there is nothing for it to wait for. The
     phone used to need a list of these — the homepage held visible under the
     growing card, the label waiting for the card to arrive, the mark's drop —
     and needs none of them now: nothing at that breakpoint transitions at all,
     whatever this query says. */
  .hero__grid.is-expanded .events-panel {
    transition-delay: 0s;
  }

  .hero-card:hover .hero-card__media {
    transform: none;
  }

  .event-postheader {
    transform: none;
  }

  .event-scroll {
    scroll-behavior: auto;
    scroll-snap-type: none;
  }

  /* Chromium cross-fades documents on navigation via @view-transition at the
     top of this file; opting the root snapshots out disables that pair. */
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}
