.hero.scroll-explosion {
  min-height: 165svh;
  overflow: clip;
}

.hero.scroll-explosion .hero-grid {
  position: sticky;
  top: 78px;
  min-height: 0;
  height: calc(100svh - 78px);
  isolation: isolate;
}

.hero.scroll-explosion .hero-copy {
  position: relative;
  z-index: 7;
  opacity: 1 !important;
  transform: none !important;
  pointer-events: none;
}

.hero.scroll-explosion .hero-copy a {
  pointer-events: auto;
}

/*
 * The headline is on screen at rest so the blast has something to act on,
 * then gets thrown clear as the bomb goes off at progress .20.
 */
.hero.scroll-explosion .hero-headline {
  --headline-exit: clamp(0, calc((var(--hero-scroll-progress, 0) - .2) * 3.4), 1);
  opacity: calc(1 - var(--headline-exit));
  transform:
    translate3d(calc(var(--headline-exit) * -130px), 0, 0)
    rotate(calc(var(--headline-exit) * -4deg));
  filter: blur(calc(var(--headline-exit) * 8px));
  will-change: transform, opacity, filter;
}

/* The call to action arrives out of the clearing smoke, once it has settled. */
.hero.scroll-explosion .hero-copy .hero-actions,
.hero.scroll-explosion .hero-copy .hero-notes {
  --resolve: clamp(0, calc((var(--hero-scroll-progress, 0) - .62) * 4.4), 1);
  opacity: var(--resolve);
  transform: translateY(calc((1 - var(--resolve)) * 26px));
}

/*
 * Opacity hides these buttons; it does not take them out of the tab order. At
 * the top of the page --resolve is 0, so tabbing out of the header used to land
 * on two links nobody could see -- and unlike everything else that reveals on
 * scroll, these are already in the viewport, so focusing them scrolls nothing
 * and reveals nothing. Focus resolves them itself.
 *
 * Only --resolve is overridden, so the smoke choreography above still owns what
 * "arrived" looks like: opacity 1 and the 26px lift already spent.
 */
.hero.scroll-explosion .hero-copy .hero-actions:focus-within {
  --resolve: 1;
}

.hero.scroll-explosion .hero-art {
  position: absolute;
  inset: 0;
  z-index: 2;
  width: 100%;
  max-width: none;
  height: 100%;
  isolation: isolate;
  transform: translate3d(calc(clamp(0, calc((var(--hero-scroll-progress, 0) - .42) * 3.6), 1) * 25%), 0, 0);
  transition: none;
}

/*
 * The stage is the lockup's own bounding box -- not the 240x240 square the
 * artwork came on. That distinction is the whole game: the wordmark hangs off
 * the left of that square and overshoots its right, so sizing the square sized
 * the wrong thing, and every breakpoint had to guess how much slack to leave
 * before the B fell off the screen.
 *
 * Sized this way, `width` means the width of the visible lockup. It cannot
 * clip, the proportions are identical at every viewport, and each breakpoint
 * below is a single honest number.
 */
.hero-logo-stage {
  position: absolute;
  left: 50%;
  top: 50%;
  translate: -50% -50%;
  width: min(75.9vw, 881px, 93vh);
  aspect-ratio: 515.875 / 460.25;
  z-index: 4;
  pointer-events: none;
}

.hero-logo-stage .hero-logo {
  position: absolute;
  transform-origin: 50% 44%;
  will-change: transform, opacity, filter;
  transition: none;

  /* styles-core.css and styles-shell.css cap .hero-logo at 710/600/500px tall
     by breakpoint. Those were written for the single flat logo that used to be
     the whole lockup; both cutouts still carry the class for its drop-shadow,
     and inherited the caps with it. Once the stage grows past about 640px the
     mascot's box hits 500px, and `object-fit: contain` then shrinks him inside
     it and floats him off his mark -- the proportions hold everywhere else and
     quietly break on wide screens. The widths are already beaten by the
     per-layer rules below; these two are not, so undo them here, where the
     two-class selector outranks .hero-logo in every media query. */
  max-width: none;
  max-height: none;
}

/*
 * Placement inside the lockup, measured off the vector artwork itself: the
 * stage is the lockup's ink box (515.875 x 460.25 of its own units, the
 * aspect-ratio above), and every figure below is where that drawing puts the
 * piece, as a fraction of that box. Printed by art/cut-brand-assets.mjs, which
 * cuts both images from the same file -- so a redraw from Boom is a re-run and
 * a paste, never a re-measure.
 *
 * The mascot's numbers describe his image's WINDOW, not his ink: mascot-still
 * is a 598x904 cut of the 734x1024 mascot.png with margin around him, and the
 * box below is that window placed so the ink inside it lands where the lockup
 * draws him. That is why it starts a little above the stage. The wordmark is
 * the widest thing in the lockup, so its box is simply the whole width.
 */
.hero-logo-bomb-layer {
  width: 49.097%;
  height: 83.191%;
  left: 22.694%;
  top: -1.472%;
}

.hero-logo-wordmark-layer {
  width: 100%;
  height: auto;
  left: 0%;
  top: 65.779%;
  transform-origin: 50% 78%;
}

/*
 * The entrance. Its finished state is the plain markup -- both cutouts sitting
 * in the lockup -- so a visitor who never runs it, or never should, gets the
 * right thing by default. `is-entering` takes pieces away; landing gives them
 * back. mascot-stage.mjs drives the walk itself, in 3D, and calls the landing
 * -- including on every path where no mascot ever arrives, because the wordmark
 * below depends on it.
 */
.hero-logo-stage.is-entering .hero-logo-bomb-layer {
  visibility: hidden;
}

/* visibility, not opacity: hero-scroll.js writes opacity inline on this element
   every scroll frame, and an inline declaration beats a stylesheet one. The
   landing keyframes below still fade it, because animations outrank inline
   styles where a static rule does not. */
.hero-logo-stage.is-entering .hero-logo-wordmark-layer {
  visibility: hidden;
  scale: .38;
}

.hero-logo-stage.is-landing .hero-logo-wordmark-layer {
  animation: boom-lands 300ms cubic-bezier(.2, 1.5, .4, 1) both;
}

/* Overshoots slightly on the way in -- it is meant to arrive like a stamp. */
@keyframes boom-lands {
  from { scale: .38; opacity: 0; }
  to { scale: 1; opacity: 1; }
}


/*
 * The walk back, set while mascot-stage.mjs owns him on the way up.
 *
 * The sprite version also forced `.hero-art { transform: none }` here, because
 * its walker was positioned in page coordinates and the art sliding 25% right
 * past progress .42 would have dragged his landing mark out from under him.
 *
 * The 3D mascot needs the opposite. His canvas is a CHILD of .hero-art, so the
 * transform carries him and the wordmark together and their register is
 * preserved for free -- while suppressing it would leave the whole hero sitting
 * in the wrong place for as long as somebody parks mid-walk, which is a state
 * scroll can hold indefinitely. So only the flash is dealt with.
 *
 * He walks onto a clean stage: the charge flash keys off scroll position, so on
 * the way up it would flare again behind him, and the bang that fires when he
 * lands is meant to be the only one.
 *
 * visibility, not opacity -- hero-scroll.js writes flash.style.opacity inline
 * every frame, and an inline declaration beats a stylesheet one. Same trap as
 * the wordmark above.
 */
.hero.scroll-explosion.is-walking-back .hero-explosion-flash {
  visibility: hidden;
}

.hero-explosion-canvas {
  position: absolute;
  inset: 0;
  z-index: 5;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

.hero-explosion-flash {
  position: absolute;
  z-index: 3;
  left: 50%;
  top: 43%;
  width: min(42vw, 390px);
  aspect-ratio: 1;
  border-radius: 50%;
  opacity: 0;
  pointer-events: none;
  transform: translate(-50%, -50%) scale(.6);
  background: radial-gradient(circle,
    rgba(255,255,255,.95) 0 3%,
    rgba(225,207,238,.72) 10%,
    rgba(135, 126, 188,.35) 34%,
    transparent 69%);
  filter: blur(5px);
  mix-blend-mode: screen;
}

.hero.scroll-explosion .spin-wheel,
.hero.scroll-explosion .orbit {
  will-change: transform, opacity;
}

/*
 * Ambient rotation keeps running until the user actually scrolls, so the hero
 * is not frozen on arrival. Scroll position then takes over the transforms.
 */
.hero.scroll-explosion.scroll-engaged .spin-wheel,
.hero.scroll-explosion.scroll-engaged .orbit {
  animation-play-state: paused;
}

.hero.scroll-explosion .scroll-cue {
  top: calc(100svh - 112px);
  bottom: auto;
  z-index: 9;
  opacity: calc(1 - clamp(0, calc(var(--hero-scroll-progress, 0) * 7), 1));
  animation: scroll-cue-nudge 2.4s ease-in-out infinite;
}

.hero.scroll-explosion.scroll-engaged .scroll-cue {
  animation: none;
}

@keyframes scroll-cue-nudge {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(6px); }
}

@media (max-width: 1000px) {
  .hero.scroll-explosion {
    min-height: 155svh;
  }

  .hero.scroll-explosion .hero-grid {
    position: sticky;
    top: 78px;
    height: calc(100svh - 78px);
    min-height: 0;
    overflow: hidden;
  }

  /*
   * Single-column layouts stack, so the full-bleed absolute .hero-art used on
   * desktop lands straight on top of the copy. Put the art and the copy in
   * two real grid rows instead: logo above, words below, no overlap.
   */
  .hero.scroll-explosion .hero-grid {
    grid-template-columns: 1fr;
    grid-template-rows: minmax(0, 1fr) auto;
    align-content: stretch;
  }

  /*
   * The mascot owns the whole opening screen. Spanning both rows rather than
   * sitting in row 1 is what buys that: row 2 still measures the copy, so the
   * space the words will land in is reserved and nothing reflows when they
   * arrive, but the logo and wheel centre against the full viewport instead of
   * being squeezed into whatever the headline leaves over.
   */
  .hero.scroll-explosion .hero-art {
    position: relative;
    /* Both column positions are explicit on purpose. Auto-placement refuses to
       overlap, so an auto column here would be pushed into an implicit second
       column — row 2 of column 1 is already the copy's — and collapse to 0
       width. Definite placement is what permits the two to share the column. */
    grid-row: 1 / -1;
    grid-column: 1;
    inset: auto;
    width: 100%;
    height: auto;
    min-height: 0;
    align-self: stretch;
    /* Recedes from the detonation itself, a beat ahead of the copy, so the
       logo has cleared row 2 before the words fade up into it. */
    --art-recede: clamp(0, calc((var(--hero-scroll-progress, 0) - .21) * 3.4), 1);
    transform:
      translate3d(0, calc(var(--art-recede) * -27%), 0)
      scale(calc(1 - var(--art-recede) * .34));
  }

  /*
   * Phones open on the mascot alone — the copy used to be on screen at rest,
   * which is what pinned the logo to a strip along the top edge. The words rise
   * out of the smoke instead, so the blast has something to deliver.
   *
   * !important throughout because the base .scroll-explosion rule pins the copy
   * to opacity 1 / transform none with !important of its own, to override the
   * .reveal observer's entrance.
   */
  .hero.scroll-explosion .hero-copy {
    grid-row: 2;
    grid-column: 1;
    align-self: end;
    padding-bottom: 8vh;
    --copy-arrive: clamp(0, calc((var(--hero-scroll-progress, 0) - .24) * 3), 1);
    opacity: var(--copy-arrive) !important;
    transform: translate3d(0, calc((1 - var(--copy-arrive)) * 30px), 0) !important;
    will-change: transform, opacity;
    /*
     * .reveal's 750ms ease is the entrance transition for its observer, and it
     * has nothing to animate here — scroll position sets the opacity directly.
     * Left in place it lags the scroll, so jumping back to the top holds the
     * copy on screen for most of a second while the logo has already returned
     * underneath it. Short enough to smooth the steps, not to trail.
     */
    transition: opacity 140ms linear;
  }

  /*
   * The copy lands over the wheel here, and the kicker is --brand-soft on
   * brand rays: measured 2.58:1 against the brightest ray while the wheel is
   * still at full strength, against 9.1:1 once it has dimmed. Reuse the scrim
   * styles-shell.css defines for the smoke blast, re-cut full width since the
   * desktop version fades out to the right where there is no copy.
   *
   * No opacity of its own: .hero-copy is a group with a fractional opacity
   * while it arrives, so the scrim rides in with the words it protects.
   */
  .hero.scroll-explosion .hero-copy::before {
    opacity: 1;
    background: rgba(8, 7, 10, .56);
  }

  /* Keyboard users can reach the buttons before scroll has revealed them. */
  .hero.scroll-explosion .hero-copy:focus-within {
    opacity: 1 !important;
    transform: none !important;
  }

  /* An invisible button must not be tappable — hero-scroll.js adds
     .copy-arrived once the copy is far enough in to be aimed at. */
  .hero.scroll-explosion .hero-copy a { pointer-events: none; }
  .hero.scroll-explosion.copy-arrived .hero-copy a,
  .hero.scroll-explosion .hero-copy:focus-within a { pointer-events: auto; }

  /*
   * The copy now animates as one block, so the desktop choreography that throws
   * the headline clear at .20 would be pulling against its own arrival.
   */
  .hero.scroll-explosion .hero-headline {
    opacity: 1;
    transform: none;
    filter: none;
  }

  /*
   * The cue is offset from the hero's own top edge, which starts 78px down
   * behind the header, so the desktop 100svh - 112px left it 16px off the
   * bottom of the screen here. It carries the whole scroll affordance now that
   * the copy waits offstage, so account for the header too.
   */
  .hero.scroll-explosion .scroll-cue {
    top: calc(100svh - 190px);
  }

  .hero-logo-stage {
    width: min(88vw, 588px);
  }

  /*
   * In the stacked layout these already occupy their space, so holding them
   * at opacity 0 until the smoke clears just leaves a dead gap under the
   * headline. Show the call to action from the start instead.
   */
  .hero.scroll-explosion .hero-copy .hero-actions,
  .hero.scroll-explosion .hero-copy .hero-notes {
    opacity: 1;
    transform: none;
  }
}

@media (max-width: 720px) {
  .hero.scroll-explosion {
    min-height: 150svh;
  }

  .hero.scroll-explosion .hero-grid {
    padding-top: 4px;
    padding-bottom: 0;
  }

  .hero.scroll-explosion .hero-copy {
    padding: 0 20px 86px;
  }

  /* Fills the opening screen now that the art spans the full grid. 96vw is a
     real 96vw of lockup, edge to edge, because the stage is the lockup. */
  .hero-logo-stage {
    width: min(96vw, 563px);
  }

  .hero-explosion-flash {
    width: min(76vw, 340px);
  }

  /*
   * Phones previously got no scroll affordance at all, which left the opening
   * as a logo with no instruction — and it is the only instruction now that the
   * copy waits offstage, so centre it and give it more room than the tablet
   * offset above: 96px off the bottom rather than 112px.
   */
  .hero.scroll-explosion .scroll-cue {
    display: block;
    left: 0;
    right: 0;
    top: calc(100svh - 174px);
    text-align: center;
  }
}

@media (prefers-reduced-motion: reduce) {
  .hero.scroll-explosion {
    min-height: calc(100svh - 78px);
  }

  .hero.scroll-explosion .hero-grid {
    position: relative;
    top: auto;
    height: auto;
    min-height: calc(100svh - 78px);
  }

  .hero.scroll-explosion .hero-copy {
    opacity: 1 !important;
    transform: none !important;
    pointer-events: auto;
  }

  /* The narrow-viewport rules above hold the links inert until scroll reveals
     the copy, and there is no scroll choreography on this path at all. */
  .hero.scroll-explosion .hero-copy a { pointer-events: auto; }

  /*
   * --hero-scroll-progress is never set on this path, so the scroll-driven
   * states would resolve to "blown away" and "not yet arrived". Show the
   * settled state instead.
   */
  .hero.scroll-explosion .hero-headline {
    opacity: 1;
    transform: none;
    filter: none;
  }

  .hero.scroll-explosion .hero-copy .hero-actions,
  .hero.scroll-explosion .hero-copy .hero-notes {
    opacity: 1;
    transform: none;
  }

  .hero.scroll-explosion .scroll-cue {
    opacity: 1;
    animation: none;
  }

  /* grid-row must come back too: a full-height art with no recede transform
     would centre the logo straight over the copy it normally lifts clear of. */
  .hero.scroll-explosion .hero-art {
    position: relative;
    grid-row: auto;
    grid-column: auto;
    inset: auto;
    width: auto;
    height: auto;
    transform: none;
  }

  /* The art row is back in normal flow here, so the stage has to carry its own
     height rather than being pinned to a parent that no longer has one. Both
     cutouts stay visible: together they are simply the logo, standing still. */
  .hero-logo-stage {
    position: relative;
    left: auto;
    top: auto;
    translate: none;
    width: min(100%, 759px);
    margin-inline: auto;
  }

  .hero-explosion-canvas,
  .hero-explosion-flash {
    display: none;
  }
}