/* ==========================================================================
   Digital Fusion Photography — layout.css
   Page shell: header, navigation, footer, containers and section patterns.
   ========================================================================== */

/* --------------------------------------------------------------------------
   Containers
   -------------------------------------------------------------------------- */

.container {
  width: 100%;
  max-width: var(--container-max);
  margin-inline: auto;
  padding-inline: var(--container-pad);
}

.container--narrow {
  max-width: var(--container-narrow);
  margin-inline: auto;
}

/* A narrow container nested inside a wide one must not pad twice — the outer
   one has already inset the content from the viewport edge. */
.container .container--narrow {
  padding-inline: 0;
}

/* --------------------------------------------------------------------------
   Vertical rhythm

   Every section pads both of its edges, then any section that directly
   follows another drops its top padding. The seam between any two blocks is
   therefore exactly one --section-gap, whatever order they appear in — which
   is what keeps the spacing down a page even instead of doubling up wherever
   two padded sections happen to meet.
   -------------------------------------------------------------------------- */

.section {
  padding-block: var(--section-gap);
}

.section + .section {
  padding-block-start: 0;
}

/* Full-bleed runs — package stacks, masonry galleries — carry no padding of
   their own, so whatever follows one has to put its own top spacing back. */
.section--flush {
  padding-block: 0;
}

.section--flush + .section {
  padding-block-start: var(--section-gap);
}

.section--flush + .band {
  margin-block-start: var(--section-gap);
}

/* --------------------------------------------------------------------------
   Contrast blocks

   A near-black section, made by remapping the colour tokens locally. Every
   component inside reads ink/paper/line, so headings, buttons, hairlines,
   review cards and focus outlines all invert without touching a single
   component rule. Near-black rather than pure black, so the true blacks in
   the photography still read against the surface.
   -------------------------------------------------------------------------- */

.section--dark,
.site-footer {
  background: #0a0a0a;
  --color-ink: #ffffff;
  --color-ink-soft: #e6e7e8;
  --color-ink-muted: #a3a6aa;
  --color-paper: #0a0a0a;
  --color-paper-warm: #141415;
  --color-line: #2c2d30;
  --color-line-soft: #202124;
  --color-line-strong: #ffffff;
  color: var(--color-ink-soft);
}

/* A colour seam must keep breathing room on BOTH edges — the no-double-gap
   arithmetic above assumes same-colour neighbours, so the boundary between a
   light and a dark section restores its top padding. */
.section + .section--dark,
.section--dark + .section {
  padding-block-start: var(--section-gap);
}

/* --------------------------------------------------------------------------
   Header
   Sticky, white, text wordmark on the left, links + Contact button on the right.
   -------------------------------------------------------------------------- */

.site-header {
  position: sticky;
  top: 0;
  z-index: var(--z-header);
  background: var(--color-paper);
  border-bottom: 1px solid transparent;
  transition: border-color var(--duration-base) var(--ease-out);
}

/* Once scrolled, the header sits on a crisp 1px black rule — no shadow. The
   whole site does elevation with contrast and hairlines, and the chrome
   should speak the same language. */
.site-header.is-stuck {
  border-bottom-color: var(--color-line-strong);
}

.site-header__inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-md);
  min-height: var(--header-height);
  padding-block: var(--space-xs);
}

/* Wordmark — a two-line uppercase lockup in place of the live site's single
   line of Times New Roman. Same face, same footprint in the 76px row (eight nav
   links and a button still have to fit), but it reads as a logotype: DIGITAL
   FUSION over a smaller, wider-tracked PHOTOGRAPHY, the fashion-label stack.
   Weight 400 because Times New Roman has no 500 to hit. */
.wordmark {
  font-family: var(--font-primary);
  font-size: 1.0625rem; /* 17px */
  font-weight: 400;
  line-height: 1.25;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--color-ink);
  text-decoration: none;
  white-space: nowrap;
  transition: opacity var(--duration-fast) var(--ease-out);
}

.wordmark__sub {
  display: block;
  font-size: 0.53125rem; /* 8.5px */
  font-weight: 400;
  letter-spacing: 0.6em;
  color: var(--color-ink-muted);
}

.wordmark:hover {
  opacity: 0.72;
}

/* Desktop navigation */
.site-nav {
  display: none;
  align-items: center;
  gap: clamp(1rem, 0.3rem + 1.4vw, 2rem);
}

.site-nav__list {
  display: flex;
  align-items: center;
  gap: clamp(0.6rem, 0.1rem + 0.9vw, 1.25rem);
  margin: 0;
  padding: 0;
  list-style: none;
}

/* Small, uppercase and wide-tracked, to match the buttons and eyebrows rather
   than sitting outside the type system as plain sentence-case links. */
.site-nav__link {
  position: relative;
  display: inline-block;
  padding-block: var(--space-3xs);
  color: var(--color-ink-muted);
  /* The nav is the one element the live site leaves on its theme font, so it
     stays Lato while the rest of the site is serif. Being a sans with a large
     x-height, it also skips the size bump the Cormorant text got. */
  font-family: var(--font-nav);
  font-size: 0.75rem; /* 12px */
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  text-decoration: none;
  white-space: nowrap;
  transition: color var(--duration-fast) var(--ease-out);
}

.site-nav__link::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: right;
  transition: transform var(--duration-base) var(--ease-out);
}

.site-nav__link:hover,
.site-nav__link:focus-visible {
  color: var(--color-ink);
}

.site-nav__link:hover::after,
.site-nav__link:focus-visible::after {
  transform: scaleX(1);
  transform-origin: left;
}

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

.site-nav__link[aria-current="page"]::after {
  transform: scaleX(1);
  height: 2px;
}

/* Hamburger */
.nav-toggle {
  display: inline-flex;
  flex-direction: column;
  justify-content: center;
  gap: 5px;
  width: 44px;
  height: 44px;
  padding: 10px;
  margin-right: -10px;
}

.nav-toggle__bar {
  display: block;
  width: 100%;
  height: 1.5px;
  background: var(--color-ink);
  transition: transform var(--duration-base) var(--ease-out),
    opacity var(--duration-fast) var(--ease-out);
}

/* nth-LAST-child, counted from the end. The bars are the last three children
   of the button but not the first three: a .visually-hidden "Menu" span sits
   ahead of them, so nth-child(1..3) selected the label, the top bar and the
   middle bar. The open state came out as a single diagonal stroke instead of
   an X — the top bar was faded out, the middle one rotated, and the bottom one
   left flat. Counting from the end is immune to anything added in front. */
.nav-toggle[aria-expanded="true"] .nav-toggle__bar:nth-last-child(3) {
  transform: translateY(6.5px) rotate(45deg);
}

.nav-toggle[aria-expanded="true"] .nav-toggle__bar:nth-last-child(2) {
  opacity: 0;
}

.nav-toggle[aria-expanded="true"] .nav-toggle__bar:nth-last-child(1) {
  transform: translateY(-6.5px) rotate(-45deg);
}

/* Mobile drawer */
.mobile-nav {
  position: fixed;
  inset: var(--header-height) 0 0 0;
  z-index: var(--z-menu);
  display: flex;
  flex-direction: column;
  padding: var(--space-lg) var(--container-pad) var(--space-2xl);
  background: var(--color-paper);
  overflow-y: auto;
  opacity: 0;
  visibility: hidden;
  transform: translateY(-8px);
  transition: opacity var(--duration-base) var(--ease-out),
    transform var(--duration-base) var(--ease-out),
    visibility var(--duration-base);
}

.mobile-nav.is-open {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
}

.mobile-nav__link {
  padding-block: var(--space-sm);
  border-bottom: 1px solid var(--color-line-soft);
  color: var(--color-ink);
  font-family: var(--font-nav);
  font-size: 1.125rem;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  text-decoration: none;
}

.mobile-nav__link[aria-current="page"] {
  font-weight: 700;
}

.mobile-nav .btn {
  margin-top: var(--space-lg);
  align-self: stretch;
}

body.nav-open {
  overflow: hidden;
}

/* The photograph viewer is a modal: it must cover the chrome, not sit on a
   scrim in front of it. At 0.92 the overlay let the white header through as a
   pale band with the wordmark ghosting under the viewer's own counter. The
   mobile drawer deliberately does NOT get this — its toggle lives in the
   header — which is why this hangs off its own class rather than .nav-open. */
body.lightbox-open .site-header {
  visibility: hidden;
}

/* The switch to the horizontal nav waits until the row genuinely fits. The old
   1000px breakpoint was about 60px short, so the Contact button was pushed
   past the right edge between 1000px and roughly 1150px.
   If you change this width, change the matchMedia call in main.js to match. */
@media (min-width: 1200px) {
  .site-nav {
    display: flex;
  }

  .nav-toggle,
  .mobile-nav {
    display: none;
  }
}

/* --------------------------------------------------------------------------
   Page hero — full-screen photograph with the page title over it

   The section fills the screen below the header and the photograph fills the
   section: object-fit: cover, so there is no letterbox at any window shape.
   That makes the crop deliberate rather than accidental — --hero-focus names
   the point the crop holds on to, and is overridden inline on any page whose
   subject does not sit in the middle of its frame.

   Visibility is the constraint the scrim solves. A full-screen photograph has
   to carry white type over whatever happens to be behind it without being
   flattened to grey, so ::after is graded rather than a flat wash: a soft
   pocket of shade under the title, a darker top edge to part the picture from
   the white header and a darker bottom edge to lead into the page, and very
   little in between.
   -------------------------------------------------------------------------- */

.page-hero {
  /* the part of the frame the crop keeps when photo and screen disagree */
  --hero-focus: 50% 50%;
  position: relative;
  display: grid;
  place-items: center;
  /* width must be stated, not left auto — see the note under min-height */
  width: 100%;
  /* Full screen means the screen below the chrome: the header is sticky and
     therefore still in flow, so a plain 100svh would push the fold down by its
     height. svh rather than vh so a phone's retracting URL bar can only ever
     reveal more of the photograph, never crop the copy; the vh line above is
     the fallback for browsers that do not know svh. */
  height: calc(100vh - var(--header-height));
  height: calc(100svh - var(--header-height));
  /* The height is definite, so the floor has to clear the tallest title block
     — the 404's oversized code sitting above its heading. It bites on landscape
     phones, where a screenful is shorter than the copy. */
  min-height: 30rem;
  padding-block: var(--space-2xl);
  overflow: hidden;
  isolation: isolate;
  background: var(--color-ink);
}

/* The hero stacks with positive z-indexes inside its own isolated context —
   photograph (1), scrim (2), title (3). Negative z-index is avoided
   deliberately: it took a filtered layer at a lower negative index painting
   over the photograph anyway, in Chrome, to learn that.

   There is no blurred fill layer under the photograph any more. It existed to
   cover the letterbox an object-fit: contain hero left over; a cover hero has
   no letterbox, and the layer went on causing the same paint-order bug — under
   prefers-reduced-motion, with no transform on the photograph to promote it,
   Chrome painted the blur on top and the hero came out soft. The section's own
   ink background covers the moment before the photograph decodes. */
.page-hero__media {
  position: absolute;
  inset: 0;
  z-index: 1;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: var(--hero-focus);
}

/* The picture-level grade, and only that. It holds the top edge against the
   white header and the bottom edge against the page, with a shallow pocket in
   the middle; through most of the frame it stays close to clear, because
   asking one wash to also guarantee the type's contrast is what turns a
   photograph grey. The type carries its own halo — see --copy-halo. */
.page-hero::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 2;
  background:
    radial-gradient(
      66% 52% at 50% 50%,
      rgba(0, 0, 0, 0.34) 0%,
      rgba(0, 0, 0, 0.18) 58%,
      rgba(0, 0, 0, 0) 100%
    ),
    linear-gradient(
      180deg,
      rgba(0, 0, 0, 0.34) 0%,
      rgba(0, 0, 0, 0.05) 22%,
      rgba(0, 0, 0, 0.07) 62%,
      rgba(0, 0, 0, 0.44) 100%
    );
}

.page-hero__inner {
  position: relative;
  z-index: 3;
  text-align: center;
  color: var(--color-paper);
}

.page-hero__title {
  color: var(--color-paper);
  text-shadow: var(--copy-halo);
  /* A page title is one long uppercase word with nowhere to wrap, so at the
     narrowest phone the display scale outgrew the gutters and the hero, being
     overflow:hidden, sliced the last letter off — WEDDINGS ran 19px and
     LIFESTYLE 15px past a 320px viewport. The vw cap only bites below about
     414px (35.2 + 6.5vw meets 15vw there), so every other size is untouched
     and the longest title now clears the gutter at 320px with room to spare.
     A media query cannot do this job: the shortfall is too abrupt to reach
     with a second linear term, and the widths here are not canon anyway. */
  font-size: min(var(--step-display), 15vw);
  font-weight: var(--weight-display);
  line-height: 1.04;
  letter-spacing: var(--tracking-caps);
  text-transform: uppercase;
}

/* Void hero — /photography-gallery.

   The one hero with no photograph in it. The gallery's own pictures fly past
   under the cursor, so the hero behind them is emptied out to a starfield:
   black, with the photographs the only lit things in the frame.

   Drawn entirely in CSS, no asset and no script. Each star is a tiny radial
   gradient and each LAYER is one tiled gradient set, so the field costs three
   background images rather than three hundred elements. The three tile sizes
   are primes — 137 / 241 / 419 — so their pattern only repeats after tens of
   thousands of pixels and the eye never finds the grid. Smaller, dimmer stars
   go in the tightest tile and the few bright ones in the widest, which is what
   reads as depth rather than as confetti.

   Stays monochrome: white at varying opacity on black, no blue. Colour here
   would be the one warm-tinted surface on an otherwise neutral site. */
.page-hero--void {
  background: var(--color-ink);
}

.page-hero--void::before {
  content: "";
  position: absolute;
  inset: 0;
  /* the slot the photograph used to occupy in the hero's stack */
  z-index: 1;
  background-image:
    /* bright, sparse — the front of the field. Each one is a hard dot inside a
       wide, near-transparent halo: a 2px dot alone is a speck, and the halo is
       what makes it read as a star at arm's length. */
    radial-gradient(circle 7px at 96px 74px, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0)),
    radial-gradient(circle 2.2px at 96px 74px, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0)),
    radial-gradient(circle 6px at 322px 268px, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0)),
    radial-gradient(circle 1.9px at 322px 268px, rgba(255, 255, 255, 0.95), rgba(255, 255, 255, 0)),
    radial-gradient(circle 7px at 214px 383px, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0)),
    radial-gradient(circle 2.4px at 214px 383px, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0)),
    /* mid */
    radial-gradient(circle 1.6px at 38px 52px, rgba(255, 255, 255, 0.85), rgba(255, 255, 255, 0)),
    radial-gradient(circle 1.4px at 173px 24px, rgba(255, 255, 255, 0.75), rgba(255, 255, 255, 0)),
    radial-gradient(circle 1.7px at 118px 196px, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0)),
    radial-gradient(circle 1.3px at 226px 141px, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0)),
    radial-gradient(circle 1.5px at 74px 227px, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0)),
    /* dust — the deepest and densest layer */
    radial-gradient(circle 1.1px at 21px 33px, rgba(255, 255, 255, 0.62), rgba(255, 255, 255, 0)),
    radial-gradient(circle 1px at 89px 12px, rgba(255, 255, 255, 0.52), rgba(255, 255, 255, 0)),
    radial-gradient(circle 1.1px at 57px 104px, rgba(255, 255, 255, 0.58), rgba(255, 255, 255, 0)),
    radial-gradient(circle 0.9px at 124px 71px, rgba(255, 255, 255, 0.46), rgba(255, 255, 255, 0)),
    radial-gradient(circle 1px at 108px 122px, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0)),
    radial-gradient(circle 0.9px at 15px 88px, rgba(255, 255, 255, 0.44), rgba(255, 255, 255, 0)),
    /* the void itself: a faint lift behind the title, black by the edges */
    radial-gradient(125% 95% at 50% 40%, #17181a 0%, #08080a 46%, #000000 74%);
  background-size:
    419px 419px, 419px 419px, 419px 419px,
    419px 419px, 419px 419px, 419px 419px,
    241px 241px, 241px 241px, 241px 241px, 241px 241px, 241px 241px,
    137px 137px, 137px 137px, 137px 137px,
    137px 137px, 137px 137px, 137px 137px,
    100% 100%;
}

/* The scrim over a photograph exists to keep white type legible; over black it
   has nothing to do, so on this hero it becomes a vignette instead — it fades
   the stars out at the frame edge, which is what stops the field reading as a
   rectangle of wallpaper that stops at the sides. */
.page-hero--void::after {
  background: radial-gradient(
    115% 88% at 50% 45%,
    rgba(0, 0, 0, 0) 46%,
    rgba(0, 0, 0, 0.42) 82%,
    rgba(0, 0, 0, 0.88) 100%
  );
}

/* Hero image trail — /photography-gallery only.

   The one hero that inserts a layer into the stack: photograph (1), scrim (2),
   trail (3), title (4). The thumbnails go above the scrim on purpose — they
   are photographs being shown off, and dimming them to match the hero behind
   would defeat the point — so the title's z-index is lifted past them here
   rather than in the shared rule.

   The layer is empty markup until a cursor crosses the hero, and stays empty
   for touch, no-JS and reduced-motion visitors. Nothing about the hero waits
   on it. pointer-events stay off the whole layer so the thumbnails cannot
   swallow a click meant for the page. */
.page-hero__trail {
  position: absolute;
  inset: 0;
  z-index: 3;
  overflow: hidden;
  pointer-events: none;
}

.page-hero--trail .page-hero__inner {
  z-index: 4;
}

/* Sized like a contact sheet frame rather than a thumbnail: big enough to read
   as a photograph at a glance, small enough that several can be alive at once
   without covering the title. 3:4, because most of the gallery is portrait. */
.trail-item {
  position: absolute;
  width: clamp(7rem, 11vw, 11rem);
  aspect-ratio: 3 / 4;
  overflow: hidden;
  /* The animation writes transform on every frame; leaving it unset here would
     paint one frame at the element's untransformed top-left corner first. */
  transform: translate(-50%, -50%) scale(0);
  will-change: transform, opacity;
  box-shadow: 0 1.25rem 2.5rem rgba(0, 0, 0, 0.35);
}

/* Against the void a drop shadow is invisible, so the separation has to be
   light rather than dark: a hairline edge and a faint bloom, as if the
   photograph were the thing emitting in the frame. */
.page-hero--void .trail-item {
  box-shadow:
    0 0 0 1px rgba(255, 255, 255, 0.14),
    0 0 2.5rem rgba(255, 255, 255, 0.08),
    0 1.5rem 3rem rgba(0, 0, 0, 0.6);
}

/* A 3:4 crop of a gallery photograph, so it needs a focal point like every
   other cropping component — see the note on .package__media img. The value is
   copied off the pool tile by initImageTrail(), because the masonry itself
   shows whole frames and has nowhere to spend one. */
.trail-item img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: var(--focus, 50% 50%);
  display: block;
}

/* The oversized error code on the 404 page — type used as a graphic element,
   sitting directly above the page title inside the same hero. */
.page-hero__code {
  font-family: var(--font-primary);
  font-size: var(--step-giant);
  font-weight: 400; /* Times New Roman has no light cut to thin this down to */
  line-height: 1;
  letter-spacing: var(--tracking-tight);
  color: var(--color-paper);
  text-shadow: var(--copy-halo);
}

/* One-shot mask reveal on hero titles: the line rises out of its own clip.
   Self-clipped (not via overflow on the parent), and time-based, so it also
   runs with JS disabled and can never strand a hidden headline. */
@media (prefers-reduced-motion: no-preference) {
  .page-hero__title,
  .hero__title {
    animation: title-rise 700ms var(--ease-out) 150ms backwards;
  }

  @keyframes title-rise {
    from {
      clip-path: inset(0 0 100% 0);
      transform: translateY(0.35em);
    }
    /* generous end inset so ascenders and descenders are never shaved */
    to {
      clip-path: inset(-0.3em -0.3em -0.2em);
      transform: translateY(0);
    }
  }
}

/* Tagline strip that sits directly under several page heroes. A pull-quote,
   not an aside: full ink, light weight, display family. No italics — only
   normal-style faces are shipped, and a synthetic slant looks cheap. */
.tagline {
  padding-block: var(--section-gap-sm);
  text-align: center;
}

.tagline p {
  max-width: 40ch;
  margin-inline: auto;
  font-family: var(--font-primary);
  font-size: clamp(1.375rem, 1.05rem + 1.6vw, 2.25rem);
  /* Times New Roman ships only 400 and 700 — the old 275 was a leftover from
     the Roboto-variable build and the browser simply rounded it to 400. */
  font-weight: var(--weight-display);
  line-height: 1.35;
  letter-spacing: -0.005em;
  color: var(--color-ink);
}

/* --------------------------------------------------------------------------
   Split — image on one side, copy on the other, alternating down the page
   -------------------------------------------------------------------------- */

.split {
  display: grid;
  gap: var(--gap-split);
  align-items: center;
}

/* Portrait sources are roughly 2:3, so at half the container width they stand
   over 800px tall while the copy beside them runs to a couple of hundred. The
   cap keeps a row from towering over its own text; cover trims evenly from the
   top and bottom rather than squashing the photograph. */
.split__media img {
  width: 100%;
  height: auto;
  max-height: clamp(20rem, 34vw, 32rem);
  object-fit: cover;
}

.split__body > * + * {
  margin-top: var(--space-md);
}

.split__title {
  font-size: var(--step-h2);
  letter-spacing: var(--tracking-wide);
}

@media (min-width: 860px) {
  .split {
    grid-template-columns: 1fr 1fr;
  }

  /* mirrored rows keep the image on the opposite side */
  .split--reverse .split__media {
    order: 2;
  }

  /* Offset variant: copy takes seven columns, the photograph five, and the
     image drops its height cap to run tall as a 4:5 portrait — asymmetry in
     place of yet another mirrored half-and-half row. */
  .split--offset {
    grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
    align-items: center;
  }

  .split--offset .split__media img {
    max-height: none;
    height: auto;
    aspect-ratio: 4 / 5;
  }
}

/* --------------------------------------------------------------------------
   Band — full-width photograph with a centred heading, used as a divider
   -------------------------------------------------------------------------- */

.band {
  position: relative;
  display: grid;
  place-items: center;
  min-height: clamp(280px, 34vw, 420px);
  padding-block: var(--space-2xl);
  overflow: hidden;
  isolation: isolate;
  text-align: center;
}

.band__media {
  position: absolute;
  inset: 0;
  z-index: -2;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* A band is far wider than the photograph's 3:2, so a desktop one keeps only
     about 55-58% of the frame's height — centred, that lands on the middle of a
     standing group and cuts them off at the calf. --focus names the point of the
     photograph pinned to the same point of the box; a HIGHER second number keeps
     more of the bottom of the frame, which is where feet are. Inert on phones,
     where the band is taller than 1.5x its width and the crop moves to X. */
  object-position: var(--focus, 50% 50%);
}

.band::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background: radial-gradient(
    115% 85% at 50% 50%,
    rgba(0, 0, 0, 0.42) 0%,
    rgba(0, 0, 0, 0.68) 100%
  );
}

.band__title {
  color: var(--color-paper);
  font-size: var(--step-h2);
  letter-spacing: var(--tracking-caps);
  text-transform: uppercase;
}

.band__body {
  position: relative;
  display: grid;
  gap: var(--space-lg);
  justify-items: center;
  color: var(--color-paper);
}

/* The body's own gap already spaces a row of buttons from the title. */
.band__body .actions {
  margin-top: 0;
}

/* Tall variant: a statement divider rather than a captioned strip. The title
   sits bottom-left at display scale, so the band reads like a chapter opener;
   the scrim shifts to a bottom gradient to anchor it. Content must sit inside
   a .band__body, which here takes over the container's job. */
.band--tall {
  /* Overridable per band: a full-bleed band is far wider than 3:2, so its
     height is what decides how much of the photograph survives the crop. The
     default keeps about 58% of the frame at 1440 but only 48% at 1850, which
     is not enough to hold a standing group whole — a band carrying one can
     raise this rather than have the photograph cropped through the people. */
  min-height: var(--band-min, clamp(420px, 62vh, 680px));
  place-items: end stretch;
  text-align: left;
}

.band--tall .band__body {
  width: 100%;
  max-width: var(--container-max);
  margin-inline: auto;
  padding-inline: var(--container-pad);
  justify-items: start;
}

.band--tall .band__title {
  font-size: var(--step-display);
  font-weight: var(--weight-display);
  line-height: 1.04;
}

.band--tall::after {
  background: linear-gradient(
    180deg,
    rgba(0, 0, 0, 0.12) 30%,
    rgba(0, 0, 0, 0.64) 100%
  );
}

/* Centred variant of the tall band. The chapter-opener's bottom-left title
   assumes the photograph's subject is not sitting there; over a standing group
   cropped so that all of it shows, it is. Centring the title moves it off the
   feet and onto the middle of the frame.

   That move costs the scrim. --tall's gradient is bottom-heavy by design — it
   anchors type sitting on the dark ground at the bottom — and hands the middle
   of the band only about 0.27 alpha, while this band's subjects are in white
   linen under a low sun. The halo alone was measured at 2.6:1 against the
   pixels beside the worst 1% of the letters, under the 3:1 that display-size
   white type needs, so a soft pocket is added under the copy as well: it takes
   the same measurement to 6.5:1 while costing the frame as a whole only about
   a tenth of its brightness (mean relative luminance 0.212 -> 0.188). It is
   deliberately wide and fully feathered — a tighter, denser wash buys the same
   contrast but starts to read as a grey oval laid across the picture, which is
   why the heroes refuse one and lean on --copy-halo alone. Here it is both. */
.band--center {
  place-items: center;
  text-align: center;
}

.band--center .band__body {
  justify-items: center;
}

.band--center .band__title {
  text-shadow: var(--copy-halo);
}

.band--center::after {
  background:
    radial-gradient(
      40% 30% at 50% 50%,
      rgba(0, 0, 0, 0.6) 0%,
      rgba(0, 0, 0, 0.38) 50%,
      rgba(0, 0, 0, 0) 100%
    ),
    linear-gradient(
      180deg,
      rgba(0, 0, 0, 0.12) 30%,
      rgba(0, 0, 0, 0.64) 100%
    );
}

/* --------------------------------------------------------------------------
   Section heading
   -------------------------------------------------------------------------- */

.section-head {
  display: grid;
  gap: var(--space-xs);
  margin-bottom: clamp(2rem, 1.4rem + 2.4vw, 3.5rem);
  text-align: center;
  justify-items: center;
}

/* A section head that is the whole section — the 404 page — has nothing to
   space itself away from. */
.section-head:last-child {
  margin-bottom: 0;
}

.section-head__title {
  font-size: var(--step-h2);
  letter-spacing: var(--tracking-caps);
  text-transform: uppercase;
}

/* Left-aligned variant, for sections whose content runs full-bleed below and
   wants an editorial, off-axis introduction. */
.section-head--left {
  text-align: left;
  justify-items: start;
}

/* A short, heavy rule under the title — the site's stand-in for an accent. */
.section-head--rule .section-head__title::after {
  content: "";
  display: block;
  width: 64px;
  height: 2px;
  margin: var(--space-sm) auto 0;
  background: var(--color-line-strong);
}

.section-head--left.section-head--rule .section-head__title::after {
  margin-inline: 0;
}

.section-head__sub {
  max-width: 56ch;
  color: var(--color-ink-muted);
  font-size: var(--text-lg);
}

.section-head__mark {
  width: clamp(112px, 14vw, 168px);
  height: auto;
  margin-bottom: var(--space-2xs);
}

/* Heading for a standalone block of copy — a step down from a section head,
   and left-aligned with the text it introduces. */
.block-title {
  font-size: var(--step-h3);
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
}

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

/* The footer is a contrast block — its colours come from the token remap in
   the "Contrast blocks" section above. No border: the cut from white page to
   near-black footer is the separation. */
.site-footer {
  padding-block: var(--space-3xl) var(--space-2xl);
  text-align: center;
}

.site-footer__inner {
  display: grid;
  gap: var(--space-lg);
  justify-items: center;
}

/* The monogram webp is black artwork on an opaque white box (no alpha).
   invert(1) turns it white-on-black, and screen blending then sinks the black
   box into the dark footer — leaving just the white mark, no plate. */
.site-footer__logo {
  width: clamp(84px, 10vw, 116px);
  height: auto;
  filter: invert(1);
  mix-blend-mode: screen;
}

/* .social-list is the component; .site-footer__socials is the name it goes by
   in the footer. Same rules, so the footer and the contact page always match. */
.social-list,
.site-footer__socials {
  display: flex;
  gap: var(--space-sm);
  margin: 0;
  padding: 0;
  list-style: none;
}

.social-list--center {
  justify-content: center;
}

.social-list a,
.site-footer__socials a {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  border: 1px solid var(--color-line);
  border-radius: 50%;
  color: var(--color-ink);
  transition: background-color var(--duration-fast) var(--ease-out),
    color var(--duration-fast) var(--ease-out),
    border-color var(--duration-fast) var(--ease-out);
}

.social-list a:hover,
.social-list a:focus-visible,
.site-footer__socials a:hover,
.site-footer__socials a:focus-visible {
  background: var(--color-ink);
  border-color: var(--color-ink);
  color: var(--color-paper);
}

.social-list svg,
.site-footer__socials svg {
  width: 20px;
  height: 20px;
  fill: currentColor;
}

/* Hairline above the small print, so the footer reads as two tiers rather than
   one undifferentiated centred stack. */
.site-footer__meta {
  position: relative;
  display: grid;
  gap: var(--space-3xs);
  padding-top: var(--space-lg);
  color: var(--color-ink-muted);
  font-size: var(--text-sm);
  line-height: var(--leading-snug);
}

.site-footer__meta::before {
  content: "";
  position: absolute;
  top: 0;
  left: 50%;
  width: 64px;
  height: 1px;
  background: var(--color-line);
  transform: translateX(-50%);
}

.site-footer__region {
  letter-spacing: var(--tracking-wider);
  text-transform: uppercase;
}

/* --------------------------------------------------------------------------
   Scroll-driven motion — page chrome and hero/band media
   Progressive: everything in here needs animation-timeline support; without
   it (older Safari, ESR Firefox) the site renders exactly as it did before.
   Authored inside prefers-reduced-motion: no-preference on purpose — the
   0.01ms kill-switch in base.css cannot flatten timeline-driven animations,
   because their duration comes from the timeline, not animation-duration.
   Two footguns, learned the hard way elsewhere:
     - the `animation` shorthand RESETS animation-timeline, so the timeline
       and range are always declared after it;
     - .page-hero/.band/.hero are overflow:hidden, which makes them scroll
       containers — an anonymous view() timeline on a child would bind to the
       section itself and never progress. Named timelines on the section
       resolve against the root scroller instead.
   -------------------------------------------------------------------------- */

/* A 2px ink hairline riding the top edge, filling with scroll progress. It
   speaks the same hairline language as the nav underline and footer rule. */
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: scroll()) {
    body::after {
      content: "";
      position: fixed;
      top: 0;
      left: 0;
      right: 0;
      height: 2px;
      z-index: calc(var(--z-header) + 1);
      background: var(--color-ink);
      transform: scaleX(0);
      transform-origin: 0 50%;
      pointer-events: none;
      animation: scroll-progress linear both;
      animation-timeline: scroll(root);
    }
  }

  @keyframes scroll-progress {
    from { transform: scaleX(0); }
    to { transform: scaleX(1); }
  }
}

/* Parallax on hero and band photography. The media bleeds past its section by
   --parallax-shift and a translateY driven by the section's own view timeline
   walks it back, so the photograph lags the page as it scrolls through.
   Desktop only: small screens gain little, lose crop area to the bleed, and
   pay the battery cost. Transform-only — layout is never touched on scroll,
   and the width/height attributes on the images keep CLS at zero.

   Heroes and bands both drift on the photograph itself. Parallax is a crop by
   definition — it walks the image past its frame — which used to disqualify
   the hero photograph and push the effect onto the blurred backdrop; now that
   the hero is a cover crop anyway, the drift belongs on the picture, where it
   reads. */
@media (prefers-reduced-motion: no-preference) and (min-width: 860px) {
  @supports (animation-timeline: view()) {
    /* Page heroes sit at the top of the page, so they only ever exit. */
    .page-hero {
      view-timeline: --page-hero block;
    }

    .page-hero__media {
      /* The bleed comes from a DEFINITE height, exactly as on .band__media
         below — never from height:auto against a negative bottom. This is an
         absolutely positioned *replaced* element: with height:auto it takes
         its height from width x intrinsic ratio and drops `bottom` on the
         floor, so the box measures exactly as tall as the photograph draws,
         object-fit: cover has nothing left to crop and object-position (and
         therefore --hero-focus) goes completely inert. Every hero then showed
         the top of its frame on desktop — walls and sky, faces below the cut —
         while the phone layout, which has no parallax, cropped correctly.
         Fixed 2026-08-14; the tuned --hero-focus values date from after it. */
      top: calc(-1 * var(--parallax-shift));
      height: calc(100% + 2 * var(--parallax-shift));
      will-change: transform;
      animation: parallax-exit linear both;
      animation-timeline: --page-hero;
      animation-range: exit;
    }

    /* The void hero has no photograph to drift, so the starfield takes the
       parallax instead — same timeline, same shift, so it lags the page the
       way every other hero's picture does. It is a painted background rather
       than a positioned image, so the bleed comes from insetting the layer
       past the section on both edges. */
    .page-hero--void::before {
      top: calc(-1 * var(--parallax-shift));
      bottom: calc(-1 * var(--parallax-shift));
      will-change: transform;
      animation: parallax-exit linear both;
      animation-timeline: --page-hero;
      animation-range: exit;
    }

    /* Bands sit mid-page: they enter and exit, so the travel is symmetric. */
    .band {
      view-timeline: --band block;
    }

    .band__media {
      top: calc(-1 * var(--parallax-shift));
      /* The bleed comes from a DEFINITE height, never from height:auto against
         a negative bottom. An absolutely positioned *replaced* element with
         height:auto takes its height from width x intrinsic ratio and drops
         `bottom` on the floor — the box then measures exactly as tall as the
         photograph draws, which leaves object-fit: cover nothing to crop and
         makes object-position (and therefore --focus) completely inert. The
         band ended up showing the top of every frame: sky, with the subject
         pushed low and sliced by the bottom edge. Verified by setting --focus
         to 70% and 20% and getting pixel-identical renders. */
      height: calc(100% + 2 * var(--parallax-shift));
      will-change: transform;
      animation: parallax-cover linear both;
      animation-timeline: --band;
      animation-range: cover;
    }
  }

  @keyframes parallax-exit {
    from { transform: translateY(0); }
    to { transform: translateY(var(--parallax-shift)); }
  }

  @keyframes parallax-cover {
    from { transform: translateY(var(--parallax-shift)); }
    to { transform: translateY(calc(-1 * var(--parallax-shift))); }
  }
}
