@layer components {

/* ══ HERO, station 0.00, inside the drum ═══════════════════════════════
   The footage is the GROUND and the type sits ON it, never a caption box
   beside it. Legibility is a LOCAL corner scrim plus a text-shadow, never a
   full-frame dim: dimming fights the image, and a linear gradient seams
   along the edge it does not fade toward, so every scrim here is radial and
   reaches transparent before all four edges.
   ==================================================================== */

/* NOT sticky. A sticky hero needs every following section to be fully opaque
   and above it, and mine are not: the headline bled through the services and
   work sections all the way down the page. Reverted. */
.hero { position: relative; }

.hero__stage {
  position: relative;
  min-height: 100svh;
  display: grid;
  /* a real composition: type anchored to the lower-left, spec rail as the
     baseline, and the light shaft in the footage left uncovered on the right */
  grid-template-columns: minmax(0, 1fr);
  grid-template-rows: 1fr auto auto;
  align-content: end;
  gap: var(--s-6);
  padding: calc(var(--s-9) + 5vh) var(--gutter) var(--s-6);
  overflow: clip;
}

/* Extra material above the frame so the parallax in hero.js has somewhere to
   travel from. MEDIA there is 0.12 of the hero's height and the translate is
   only ever downward, so the headroom lives at the top and 15% clears it with
   margin. .hero__stage clips, so none of this can widen the document. */
.hero__media { position: absolute; inset: -15% 0 0 0; z-index: 0; }

/* THE HERO VIDEO. This rule was deleted when the hero briefly swapped to a
   photograph and never restored, so the video fell back to normal flow at its
   intrinsic 1280x548 and left dead black to the right and below. It must be
   absolutely positioned and cover the stage. */
.hero__video {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  object-position: 50% 55%;
  /* NO filter here. The grade used to be
     `filter: brightness(0.74) contrast(1.06) saturate(0.94)`, and a filter on a
     PLAYING full-viewport video re-runs the whole filter pass on every decoded
     frame: measured p95 33.4ms -> 24.9ms across the hero with it removed, 9 of
     13 scroll frames over budget down to 2. brightness(0.74) is exactly a 26%
     black multiply, so it moves to a flat layer in .hero__scrim below, which
     the compositor draws once. The contrast/saturate tweaks were a percent or
     two and are not worth a per-frame pass to keep. */
  /* a very slow push so the frame is never quite static */
  transform: scale(1.02);
  transition: transform 18s linear;
  /* No will-change. It was set here permanently, which held a promoted
     compositor layer for a full-viewport decoding video for the whole life of
     the page, long after the one 18s transition had settled. An 18-second
     linear transform is not a frame-budget risk that needs the hint. */
}
.hero[data-ready='true'] .hero__video { transform: scale(1.09); }

.hero__scrim {
  position: absolute; inset: 0;
  background:
    /* The type well. Original composition anchored this at the corner the
       type occupied. Measured when the stack moved to the centre: a centred
       version of the same gradient dimmed the footage 91.6% and a lowered
       one still cost 41.7%. The footage is the ground this page is built on,
       so the well stays in the corner and off the picture, and the type
       carries itself on its own shadow. */
    radial-gradient(150% 118% at 0% 100%,
      oklch(0.09 0.012 250 / 0.97) 0%,
      oklch(0.09 0.012 250 / 0.88) 26%,
      oklch(0.09 0.012 250 / 0.52) 50%,
      oklch(0.09 0.012 250 / 0.16) 68%,
      transparent 82%),
    /* The nav band. Measured per label: at 42% centre the right-hand items sat
       over bright sky and read 3.41:1 (FAQ) and 3.43:1 (Contact), both below
       AA. This spans the full width and reaches transparent before the lower
       edge, so it darkens the band without seaming or dimming the photograph. */
    radial-gradient(150% 62% at 50% -12%,
      oklch(0.10 0.012 250 / 0.90) 0%,
      oklch(0.10 0.012 250 / 0.62) 34%,
      oklch(0.10 0.012 250 / 0.24) 62%,
      transparent 84%),
    /* The grade, moved off .hero__video. A flat layer on a static element, so
       the compositor draws it once instead of re-running a filter on every
       decoded video frame.
       0.38, not the 0.26 that matched the old brightness(0.74). The parallax
       gave .hero__media a -15% inset, which slid brighter footage under the
       type: measured against the brightest ground pixel beneath each line the
       eyebrow fell to 2.86:1 at 1440 and the headline to 3.01:1 at 390, both
       failing. A localised ellipse behind the type fixed the numbers and was
       the wrong answer, because a soft dark blob on a photograph is a shape
       you can see. This grades the WHOLE frame instead, evenly, which is what
       the footage had before any of this and reads as exposure rather than as
       an object sitting on the picture. */
    linear-gradient(oklch(0.09 0.012 250 / 0.46), oklch(0.09 0.012 250 / 0.46));
}

/* ── type block ── sized in its OWN em so the display face never clips ── */
.hero__type {
  position: relative; z-index: 1;
  grid-row: 2;
  align-self: end;
  /* One centred stack: mark, eyebrow, headline, lede, one action. */
  text-align: center;
}

/* The mark leads the stack. Sized off the headline so the two scale together
   rather than drifting apart between breakpoints. */
.hero__logo {
  display: block;
  /* the real lockup: isometric mark over the M3D CONCRETE wordmark. It carries
     its own chrome, so it does not inherit currentColor. */
  width: clamp(148px, 11vw + 42px, 250px);
  height: auto;
  margin: 0 auto var(--s-3);
  filter: drop-shadow(0 1px 2px oklch(0.06 0.01 250 / 0.92))
          drop-shadow(0 3px 20px oklch(0.06 0.01 250 / 0.8));
}

/* The eyebrow sits on the brightest part of the frame and is the smallest type
   on it, so it needs 4.5:1 rather than the headline's 3:1. Two shadows: a
   tight one right on the glyph edge that does the contrast work, and the
   original soft one for the halo. The footage under it is MOVING, so a single
   measurement is a sample of one video frame; a shadow raises the floor on
   every frame without putting a visible shape on the photograph. */
.hero__type .eyebrow {
  color: var(--fog-max);
  text-shadow: 0 1px 2px oklch(0.06 0.01 250 / 0.95),
               0 1px 14px oklch(0.07 0.01 250 / 0.95);
}

.hero__h1 {
  font-size: var(--step-h1);
  /* 8.2em at the h1's own size, "Bringing" always fits */
  max-width: min(8.2em, 100%);
  margin-block: var(--s-4) 0;
  margin-inline: auto;
  color: var(--fog-max);
  /* The 14.17:1 that used to be quoted here was measured when this stack sat
     in the lower-left corner, directly on the scrim well. The stack is centred
     now and the well is still anchored at 0% 100%, so the right-hand half of
     every line sits on materially less of it: treat that number as gone, not
     as current. The trade is deliberate and documented on .hero__scrim below,
     the footage keeps its exposure and the type carries itself.
     The old 30px shadow at 0.78 alpha read as a halo around every glyph, which
     is the cheapest-looking thing on a page. One tight shadow for the edge
     cases where a bright patch drifts under a line. */
  text-shadow: 0 1px 8px oklch(0.06 0.01 250 / 0.55);
}

/* per-line clip mask: the reveal is clip + translate. Nothing fades.
   overflow-clip-margin keeps descenders and the italic sidebearing alive. */
.hero__h1 .ln {
  display: block;
  overflow: clip;
  overflow-clip-margin: 0.14em;
  padding-inline-end: 0.06em;   /* room for the final letter's sidebearing */
}
.hero__h1 .ln > span {
  display: block;
  /* 105% did not clear the 0.14em overflow-clip-margin above, so slivers of
     the letters painted outside the mask and the headline read as broken
     fragments for the first half second. Park it well past the margin. */
  translate: 0 140%;
  transition: translate 1600ms var(--ease-out-expo);
}
/* the headline starts after the mark and the place names have landed. A
   little more air between the lines: at 95ms they read as one block moving,
   at 115ms as three lines arriving. */
.hero__h1 .ln:nth-child(1) > span { transition-delay: 620ms; }
.hero__h1 .ln:nth-child(2) > span { transition-delay: 770ms; }
.hero__h1 .ln:nth-child(3) > span { transition-delay: 920ms; }
.hero[data-ready='true'] .hero__h1 .ln > span { translate: 0 0; }

/* One entrance, in reading order: mark, place names, headline (its own
   per-line clip above), lede, and the action LAST so the eye finishes on the
   thing to click.
   A CSS animation is used rather than a transition because initTextFade owns
   the inline opacity on these blocks. An animation outranks an inline style
   while it runs, and `backwards` hands control straight back when it ends, so
   the scroll envelope keeps working afterwards. */
/* One 18px slide reused for four very different things read as four copies of
   the same move. Each element now arrives the way its own weight suggests:
   the mark settles out of a slight scale, the small type lifts a short
   distance, and the button lands last. Transform and opacity only, so the
   whole entrance stays on the compositor. */
@keyframes heroMark {
  from { opacity: 0; translate: 0 14px; scale: 0.94; }
  to   { opacity: 1; translate: 0 0;    scale: 1; }
}
@keyframes heroRise {
  from { opacity: 0; translate: 0 9px; }
  to   { opacity: 1; translate: 0 0; }
}
@keyframes heroLand {
  from { opacity: 0; translate: 0 10px; scale: 0.985; }
  to   { opacity: 1; translate: 0 0;    scale: 1; }
}
.hero[data-ready='true'] .hero__logo {
  animation: heroMark 1500ms var(--ease-out-expo) 120ms backwards;
}
.hero[data-ready='true'] .hero__type .eyebrow {
  animation: heroRise 1150ms var(--ease-out-expo) 430ms backwards;
}
.hero[data-ready='true'] .hero__lede {
  animation: heroRise 1200ms var(--ease-out-expo) 1120ms backwards;
}
.hero[data-ready='true'] .hero__actions {
  /* `backwards` holds opacity 0 for the whole delay, and data-ready itself
     does not land until the loader clears at ~2.6s. At 1480ms the page's only
     above-the-fold action was invisible for ~4.1s and unsettled until ~5.2s.
     Pulled in 300ms. It still lands last, after the lede, which is the point
     of the choreography; it just stops being the slowest thing on the page. */
  animation: heroLand 1050ms var(--ease-out-expo) 1180ms backwards;
}

.hero__lede {
  font-size: var(--step-lead);
  color: var(--fog-900);
  max-width: 44ch;
  margin-top: var(--s-5);
  margin-inline: auto;
  text-shadow: 0 1px 10px oklch(0.07 0.01 250 / 0.4);
}

.hero__actions {
  display: flex; flex-wrap: wrap; gap: var(--s-3);
  justify-content: center;
  margin-top: var(--s-6);
}

@media (max-width: 900px) {
  /* end-alignment is a desktop composition. On a phone it stranded the mark
     and the headline in the bottom third with a dead band above them.
     align-content alone does nothing here: row 1 is `1fr` and eats every
     spare pixel, so the free space has to be split above AND below instead. */
  .hero__stage {
    grid-template-rows: 1fr auto 1fr;
    align-content: center;
    padding-top: var(--s-8);
  }
  /* 36%, not 58%. The footage is 16:9 and this box is 9:19.5, so `cover`
     shows a ~260px-wide window onto the 1152px source and the rest is thrown
     away. At 58% that window sat on source x 517..778, which is the dark cab
     interior: Max, who works left of centre in the shots he appears in, was
     off the edge of it. 36% puts the window on x 320..580, which is his head,
     shoulder and torso. Checked by cropping the source frames to the exact
     window the browser computes, rather than by eye. */
  /* The static fallback frames the POSTER, which is frame one: Max at the
     truck door: Max works left of centre in the shots he appears in, and the
     58% this replaced was looking to the right of him, which is why he was
     cut out. One static value, nothing moving at runtime. */
  .hero__video { object-position: 36% center; }
}
@media (max-width: 640px) {
  .hero__lede { max-width: none; }
}

/* JS off / reduced motion: the final state is already in place */
.no-js .hero__h1 .ln > span { translate: 0 0; transition: none; }

@media (prefers-reduced-motion: reduce) {
  /* Specificity, not source order, decides this. `.hero[data-ready] .hero__video`
     is (0,2,1); a bare `.hero__video` here is (0,1,0) and silently loses, which
     is exactly what the harness caught: transform still computed to
     matrix(1.065) under reduced motion. Match the specificity to win. */
  .hero__h1 .ln > span { translate: 0 0; transition: none; }
  .hero[data-ready='true'] .hero__logo,
  .hero[data-ready='true'] .hero__type .eyebrow,
  .hero[data-ready='true'] .hero__lede,
  .hero[data-ready='true'] .hero__actions { animation: none; }
  .hero[data-ready='true'] .hero__video,
  .hero__video { transform: none; transition: none; }
}
}

@layer components {
/* ══ STATION 01, THE DESCENT ═════════════════════════════════════════
   The one place generated footage appears, and it is labelled there.
   Two columns so the footage is a held object rather than a backdrop , 
   the page has already made its claim on real photography above. */
.descent {
  display: grid;
  /* media leads: concrete is sold on surface, and surface dies small */
  grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
  gap: clamp(1.5rem, 4vw, 5rem);
  align-items: center;
  padding: var(--s-10) var(--gutter);
  background: var(--ink-000);
}
.descent__media {
  position: relative; margin: 0;
  aspect-ratio: 4 / 3;
  border-radius: var(--r-card);
  overflow: clip;
  background: var(--ink-100);
}
.descent__video { width: 100%; height: 100%; object-fit: cover; }
.descent__scrim {
  position: absolute; inset: 0;
  background: radial-gradient(120% 90% at 50% 110%,
    oklch(0.10 0.012 250 / 0.55) 0%, transparent 62%);
}
.descent__type h2 {
  /* NOT --step-h2: that scale is built for a full-bleed hero. In a half
     column it wraps to eight lines and swamps the media beside it. */
  font-size: clamp(1.85rem, 1.1rem + 2.1vw, 3.15rem);
  line-height: 1.02;
  letter-spacing: -0.022em;
  max-width: 15ch;
  margin-block: var(--s-4) var(--s-5);
}

.descent__body { max-width: 46ch; color: var(--fog-700); }
@media (max-width: 900px) {
  .descent { grid-template-columns: 1fr; padding-block: var(--s-9); }
}
}

@layer components {
  /* alternate the media side so the two stations do not read as a ladder , 
     the alternating split is the single most template-ish pattern in the
     competitor set (infiniteconcretecreations runs it four times) */
  .descent--flip .descent__media { order: 2; }
  .descent--flip { background: var(--ink-050); }
  @media (max-width: 900px) { .descent--flip .descent__media { order: 0; } }
}
