/* Opening hero (Study 1 — oversized architectural crop, bone-on-graphite) */

.hero-1.sheet {
  /*
    Selector is .hero-1.sheet, not just .hero-1 — the hero's actual class
    is "hero-1 sheet", and css/sections.css's plain .sheet rule (position:
    relative) has identical specificity to a plain .hero-1 rule. Since
    sections.css loads after hero.css, that tie was silently won by .sheet,
    reverting the hero to position:relative the entire time — sticky was
    declared but never actually applied. The compound selector here beats
    .sheet's specificity regardless of stylesheet order, so this can't
    silently regress again if the load order ever changes.

    Sticky, pinned right below the fixed header, so it holds still while
    the mark blurs in and #about slides up to cover it (the pin-and-cover
    opening).

    top is header-h, NOT header-h + tab-h like the sections. The tab
    clearance exists so a section's folder tab has room below the header;
    the hero has no tab, so pinning it that extra 34px lower would just
    open a 34px gap under the header at the top of the page (its natural
    position is at header-h, and sticky would shove it down to the larger
    offset). The hero is opaque and #about is opaque, so #about simply
    covers it natively as it rises — no need for the offsets to match.
  */
  position: sticky;
  top: var(--header-h);
  overflow-anchor: none;
  min-height: var(--hero-h);
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  overflow: hidden;
  background: var(--ground); /* opaque — covers the section frost behind it while the hero is on screen */
  border-bottom: 1px solid var(--rule);
  /*
    Own stacking context, so the photograph's tone layer and the mark
    composite against the hero and nothing else — never against the sheets
    rising to cover it.
  */
  isolation: isolate;
}

/*
  ---- The opening photograph (FULL BLEED) ----

  Edge to edge. The earlier specimen-plate split (photo column + graphite
  void) was tested in explorations/hero-photo/ and rejected in favour of
  this — the composition is better served by the photograph carrying the
  whole frame.

  isolation:isolate confines the tone layer's blend mode to this box, so a
  grade applies ONLY to the photograph and cannot leak onto the mark or the
  page behind it.
*/
.hero-1__photo {
  position: absolute;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  isolation: isolate;
}

.hero-1__photo img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  /*
    ---- THE CROP ----

    Matched to references/04-photography/shanghai-cropReference.png by
    locating that crop inside the master via normalised cross-correlation
    (0.975 match). It resolves to x 5.5%-93.6%, y 30.5%-85.7% of the
    source: horizontally centred, vertically centred at 58.1%, and showing
    just 55% of the image's height.

    Reaching that needs two things working together:

    object-position 50% 79%  puts source-y 58.1% at the centre of the box
                             — the centre of the reference crop. (79% looks
                             arbitrary but is not: object-position
                             distributes the OVERFLOW, which at cover is
                             only 27.7% of the image's height here.) This
                             value is correct at ANY zoom, because the zoom
                             scales about that same centre.
    scale(1.16)              then chooses how close.

    On the zoom specifically: the reference crop is a 1.97-wide rectangle
    and the hero is ~1.71, so the reference cannot be reproduced exactly —
    something has to give. Matching its HEIGHT exactly needs scale(1.31),
    which then holds only 76% of the image's width and cuts the outer deck
    and the left-hand light off the sides. Matching its WIDTH needs
    scale(1.14). 1.16 sits deliberately near the width-matching end,
    because what the reference is actually showing is the whole working
    scene — both hands, both decks — and losing the edges of that costs
    more than a little extra headroom does.

    Computed against the hero's real height (100vh MINUS the header, not
    the full viewport) — getting that wrong is what cropped the eyes off on
    an earlier attempt.

    Because the zoom is a transform about the box centre, the two controls
    stay independent: object-position chooses WHAT is centred, --photo-zoom
    chooses HOW CLOSE. Neither touches the master, so this is all
    reversible.
  */
  object-position: 50% var(--photo-y, 79%);
  --photo-zoom: 1.15;
  --photo-shift-y: -8.5%;
  --photo-brightness: 0.56;
  /*
    --photo-push is written by js/main.js during scroll (1 -> 1.26) and
    multiplies the base zoom, so the scroll motion composes with the crop
    instead of overwriting it.

    --photo-shift-x / -y nudge the framing from that base, and are the
    controls to reach for when the crop needs moving. They are translates
    rather than object-position adjustments for a specific reason: at
    desktop sizes object-position's X does NOTHING here. `cover` is
    width-driven at this frame ratio, so the image exactly fills the box
    horizontally and there is no overflow left for object-position to
    distribute. A translate works on both axes, at any zoom, at any
    viewport. Note the shift is applied inside the scale, so it is
    multiplied by it — a 1% nudge moves ~1.16% of the frame at the current
    zoom.
  */
  transform:
    scale(calc(var(--photo-zoom) * var(--photo-push, 1)))
    translate(var(--photo-shift-x, 0%), calc(var(--photo-shift-y, 0%) + var(--photo-rise, 0%)));
  transform-origin: center center;

  /*
    ---- THE GRADE: NATURAL ----

    No processing at all. Chosen after comparing five treatments side by
    side in explorations/hero-photo/ (natural, vivid, warm, warm+, and the
    rejected graphite). The photograph is well exposed and already carries
    its own colour, and every correction tried — desaturating toward
    graphite, or warming the blue stage light toward amber — cost more in
    life than it returned in palette obedience.

    So the palette accommodates the photograph rather than the reverse.
    Ember stays the page's only deliberate accent; the hero simply does not
    compete with it, because the scrims below keep the photograph's edges
    dark and the accent rule sits on solid ground.

    Expressed as brightness(1) rather than `none` because 1 is the identity
    value — visually identical to no filter, but it leaves a live knob the
    grade comparison can drive without production having to declare a
    filter it does not otherwise want.
  */
  /*
    --photo-dim is written by js/main.js during scroll (1 -> 0.55) and
    MULTIPLIES the resting brightness, exactly as --photo-push multiplies
    the resting zoom. The base value stays declared here in CSS where it is
    documented, and the scroll effect composes with it rather than
    replacing it — so changing 0.56 later automatically carries through to
    the dimmed end of the range.
  */
  filter: brightness(calc(var(--photo-brightness, 1) * var(--photo-dim, 1)));
}

/*
  Tone layer — present but OFF (opacity 0). Kept as the hook the grade
  comparison in explorations/hero-photo/ switches on, so the warm and
  graphite treatments can still be evaluated against the chosen natural
  one without editing production CSS. Costs nothing while transparent.
*/
.hero-1__photo::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  background: #F0A25A;
  mix-blend-mode: soft-light;
  opacity: 0;
  pointer-events: none;
}

/*
  Dissolves the photograph's edges into --ground so the hero reads as lit
  space rather than a pasted rectangle, and so the tagline sits on near
  solid ground instead of on busy highlights. Painted above the tone layer,
  in normal blend mode, so it stays a true black scrim.
*/
.hero-1__photo::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 2;
  background:
    linear-gradient(to top, var(--ground) 0%, rgba(12, 13, 15, 0.8) 9%, rgba(12, 13, 15, 0.25) 24%, rgba(12, 13, 15, 0) 44%),
    linear-gradient(to right, rgba(12, 13, 15, 0.6) 0%, rgba(12, 13, 15, 0) 34%);
  pointer-events: none;
}

/* ==================================================================
   THE OPENING SEQUENCE — docs/hero-motion.md §3
   ==================================================================

   "You pass through the signature into the room."

   Three states, driven by two classes that js/main.js puts on <html>:

     .is-intro           first frame — the signature, held
     .is-intro-running   transitioning to the resting state
     (neither)           the resting state, identical to scroll-progress 0

   The resting state is NOT re-declared here. It is simply the absence of
   .is-intro, so the sequence animates INTO the values already declared
   above and in tokens.css. There is exactly one source of truth for where
   this lands (§7 rule 1), which is why no seam can open at the handoff.

   The two elements are choreographed in unison but take different curves,
   because they are playing different roles. Giving them the same easing
   would read as two animations that happen to overlap:

     photograph  decelerating, with ONE overshoot — a lens finding focus
     mark        accelerating away — something passing you and leaving

   One overshoot, not a bounce: multiple oscillations read playful and
   consumer-grade, a single settle reads precise. And it overshoots PAST
   the rest point (1.135, below 1.15) then returns — rebounding back toward
   where it came from would look wrong.
*/

/* Ease-out with overshoot, for the photograph settling into frame. */
:root {
}

/* ---- Phase 1: the first frame, held ---- */
.is-intro .hero-1__photo img {
  --photo-zoom: 1.63;
}
/*
  100vw at X 0 / Y −10vw, expressed as a transform of the RESTING box
  (180vw at −55 / −40vw) rather than by changing --mark-w/x/y — see §7
  rule 5. With transform-origin at the top-left, a point p maps to T + s·p,
  so the box's own top-left lands at T:

    The three values live in css/tokens.css as --intro-mark-s / -tx / -ty,
  with their own mobile set, so the phone opens on a framing that was
  chosen rather than one that falls out of the desktop arithmetic.

  transform-origin: 0 0 is set unconditionally on the base rule above —
  these figures are only correct with a top-left origin.
*/
/*
  The frame painted before the animation starts — identical to the
  animation's own 0% keyframe, so the handoff is seamless.
*/
.is-intro .hero-1__mark-wrap {
  transform: translate(var(--intro-mark-tx0), var(--intro-mark-ty0)) scale(var(--intro-mark-s0));
  opacity: 0;
  filter: blur(6px);
}

/*
  ---- Phase 0: up from black ----

  The page opens black and the first frame resolves out of it, rather than
  simply being there. Two reasons beyond taste:

  It hides the join. Between first paint and the sequence starting there is
  an unavoidable, variable gap — two animation frames plus however long the
  photograph takes to decode. Without a cover that shows as a split second
  of a static hero before anything moves. A black plate makes the start
  deterministic.

  The plate runs 600ms but is FRONT-LOADED, and the easing is doing the
  real work here — not the duration.

  The spring's large pull-back completes in roughly the first 240ms, so a
  long LINEAR fade would buy its extra length by keeping that motion
  hidden, which is the fault this was fixed for once already. An ease-out
  sheds most of the plate's density early — about 60% gone by 100ms, 85% by
  200ms — and then lets the last faint veil dissolve slowly to 600ms.

  So the image is clearly visible while it is still moving, and the fade
  still feels unhurried. Lengthening this further should be done by
  stretching the tail, never by flattening the curve.

  The motion begins at t=0, CONCURRENTLY with this fade, rather than
  after it. An explicit 500ms hold used to sit between the two, and it read
  exactly as the fault the plate was meant to cure: the image resolving out
  of black and then visibly waiting. The hold is not lost, though — both
  curves start from rest, so the opening frame still lingers long enough to
  be read. The signature's legible moment comes from the easing, not from a
  timer.

  And it is a COVER, not a fade on the image. The photograph paints at full
  opacity underneath from the very first frame; only the plate above it
  fades. Fading the image itself would delay the largest contentful paint
  for the sake of a transition nobody asked for.
*/
.hero-1.sheet::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 5; /* above photo (0), mark (1) and content (2) */
  background: var(--ground);
  opacity: 0;
  pointer-events: none;
}
.is-intro .hero-1.sheet::after { opacity: 1; }
.is-intro-running .hero-1.sheet::after {
  transition: opacity 600ms cubic-bezier(0.16, 0.84, 0.3, 1);
}

/*
  ---- Ambient drift (docs/hero-motion.md §4) ----

  Once the sequence has settled and before the first scroll, the hero
  breathes very slightly — the same black plate that opened the page,
  drifting between 0 and 3.5% over 24 seconds. The point is that the hero
  should feel lit rather than like a static JPEG.

  Deliberately half the amplitude instinct suggests. Continuous ambient
  loops read as screensaver, and on a booking site a visibly pulsing image
  reads as a rendering fault. At this depth it is felt rather than noticed.

  It reuses the intro's plate rather than adding a layer, and it drives
  OPACITY while the scroll phase drives --photo-brightness — so idle and
  scroll can never write the same property and fight (§7 rule 3).

  Gated on :not(.is-scrolled) so it runs only in the genuine idle window,
  and returns if the visitor scrolls back to the top and stops. Under
  prefers-reduced-motion, base.css collapses every animation-duration, so
  this stops dead without needing its own guard.
*/
.intro-done:not(.is-scrolled) .hero-1.sheet::after {
  animation: hero-breathe 24s ease-in-out infinite;
}
@keyframes hero-breathe {
  0%, 100% { opacity: 0; }
  50% { opacity: 0.035; }
}

.is-intro-running .hero-1__mark-wrap {
  animation: mark-arrive-pass 1350ms linear both;
}
/*
  TWO ACTS, one animation.

  ARRIVES (0 - 26%, ~350ms): the mark materialises WITH the fade rather than
  being uncovered by it. It grows out of a smaller frame, resolves from soft
  to sharp, and rises to its peak ink, on the same front-loaded curve as the
  black plate. Before this it did none of those things — measured, it had
  moved 3% of its travel and changed ink by 0.012 by the time the plate had
  cleared, which is precisely why it read as not belonging to the opening.

  PASSES (26% - 100%): it keeps growing, accelerating away past the viewer,
  and dims to its resting ink.

  The blur is therefore an ENTRANCE, used once, ending sharp. That is not a
  stylistic preference: the resting mark is sharp, so any mid-flight blur
  MUST return to zero — the out-and-back is structural, and moving the blur
  to the entrance is the only way to have it resolve in one direction.

  Growth is continuous and accelerating throughout, which is what something
  travelling toward you and passing actually does.
*/
@keyframes mark-arrive-pass {
  0% {
    transform: translate(var(--intro-mark-tx0), var(--intro-mark-ty0)) scale(var(--intro-mark-s0));
    opacity: 0;
    filter: blur(6px);
    animation-timing-function: cubic-bezier(0.16, 0.84, 0.3, 1); /* matches the plate */
  }
  26% {
    transform: translate(var(--intro-mark-tx), var(--intro-mark-ty)) scale(var(--intro-mark-s));
    opacity: 0.58; /* the one moment the mark is legible */
    filter: blur(0px);
    animation-timing-function: cubic-bezier(0.5, 0, 0.9, 0.4); /* accelerating away */
  }
  100% {
    /* Identical to the resting style, so nothing jumps when the animation
       is removed. transform:none puts it exactly on the geometry the frost
       is registered to. */
    transform: none;
    opacity: var(--mark-ink);
    filter: blur(0px);
  }
}

/*
  The page frame — header and footer — BLURS in, and arrives during the
  pass-through rather than after it. Having it appear once everything had
  settled read as the page assembling itself in the wrong order: the
  furniture arriving after the room was finished.

  Starting at 300ms it is resolving while the mark is still travelling, so
  the frame and the photograph settle together.
*/
.is-intro .strip,
.is-intro .social-bar {
  opacity: 0;
  filter: blur(12px);
}
/* Hero type still arrives on the tail — it is content, not frame, and it
   should land after the photograph has found its position. */
.is-intro .hero-1__content {
  opacity: 0;
  transform: translateY(12px);
}

/* ---- Phase 2 + 3: the pass-through, and the arrival ---- */
/*
  The photograph settles with ONE overshoot — it pulls back slightly past
  its resting frame, then eases back into it, like a lens finding focus.

  This is a keyframe animation rather than a transition with an overshoot
  easing, because the easing approach failed quietly. `cubic-bezier(0.22,
  1.12, 0.36, 1)` looks like it overshoots — its control point is above 1 —
  but the curve actually peaks at 1.0035, a 0.35% overshoot of the travel.
  Against a 1.63→1.15 move that is about half a percent of scale: real in
  the maths, invisible on screen.

  Keyframes make the overshoot an explicit VALUE instead of an emergent
  property of a curve, so it can be dialled directly: 1.105 is the far
  point, 1.15 the rest. Going past the target and returning is what reads
  as a settle; rebounding back toward where it came from would read as a
  bounce, which is the wrong register for this page.
*/
.is-intro-running .hero-1__photo img {
  animation: photo-open 1350ms linear both;
}
@keyframes photo-open {
  /* Generated from a damped second-order step response (zeta 0.48,
     omega-n 13): three decaying oscillations, each 18% of the one
     before, which is what reads as a rubber band rather than a single
     settle. Sampled at 45 stops and interpolated linearly BETWEEN them
     (animation-timing-function: linear on the rule below) — the
     curvature lives in the values, so an easing function applied on top
     would distort it.

     Each stop is a MULTIPLE of --photo-zoom rather than an absolute
     scale, so the same spring works at any resting zoom. That matters:
     mobile rests at 1.38 rather than 1.15, and hardcoded scales would
     have made the phone open at the wrong distance. */
  0% { transform: scale(calc(var(--photo-zoom) * 1.4174)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  2.273% { transform: scale(calc(var(--photo-zoom) * 1.4009)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  4.545% { transform: scale(calc(var(--photo-zoom) * 1.3582)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  6.818% { transform: scale(calc(var(--photo-zoom) * 1.2991)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  9.091% { transform: scale(calc(var(--photo-zoom) * 1.2319)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  11.36% { transform: scale(calc(var(--photo-zoom) * 1.1639)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  13.64% { transform: scale(calc(var(--photo-zoom) * 1.1003)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  15.91% { transform: scale(calc(var(--photo-zoom) * 1.0448)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  18.18% { transform: scale(calc(var(--photo-zoom) * 0.9997)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  20.45% { transform: scale(calc(var(--photo-zoom) * 0.9657)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  22.73% { transform: scale(calc(var(--photo-zoom) * 0.9427)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  25% { transform: scale(calc(var(--photo-zoom) * 0.9297)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  27.27% { transform: scale(calc(var(--photo-zoom) * 0.9252)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  29.55% { transform: scale(calc(var(--photo-zoom) * 0.9275)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  31.82% { transform: scale(calc(var(--photo-zoom) * 0.9347)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  34.09% { transform: scale(calc(var(--photo-zoom) * 0.9450)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  36.36% { transform: scale(calc(var(--photo-zoom) * 0.9569)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  38.64% { transform: scale(calc(var(--photo-zoom) * 0.9692)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  40.91% { transform: scale(calc(var(--photo-zoom) * 0.9807)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  43.18% { transform: scale(calc(var(--photo-zoom) * 0.9909)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  45.45% { transform: scale(calc(var(--photo-zoom) * 0.9992)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  47.73% { transform: scale(calc(var(--photo-zoom) * 1.0055)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  50% { transform: scale(calc(var(--photo-zoom) * 1.0099)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  52.27% { transform: scale(calc(var(--photo-zoom) * 1.0124)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  54.55% { transform: scale(calc(var(--photo-zoom) * 1.0134)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  56.82% { transform: scale(calc(var(--photo-zoom) * 1.0131)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  59.09% { transform: scale(calc(var(--photo-zoom) * 1.0119)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  61.36% { transform: scale(calc(var(--photo-zoom) * 1.0101)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  63.64% { transform: scale(calc(var(--photo-zoom) * 1.0080)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  65.91% { transform: scale(calc(var(--photo-zoom) * 1.0058)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  68.18% { transform: scale(calc(var(--photo-zoom) * 1.0037)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  70.45% { transform: scale(calc(var(--photo-zoom) * 1.0018)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  72.73% { transform: scale(calc(var(--photo-zoom) * 1.0003)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  75% { transform: scale(calc(var(--photo-zoom) * 0.9991)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  77.27% { transform: scale(calc(var(--photo-zoom) * 0.9983)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  79.55% { transform: scale(calc(var(--photo-zoom) * 0.9978)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  81.82% { transform: scale(calc(var(--photo-zoom) * 0.9976)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  84.09% { transform: scale(calc(var(--photo-zoom) * 0.9976)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  86.36% { transform: scale(calc(var(--photo-zoom) * 0.9978)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  88.64% { transform: scale(calc(var(--photo-zoom) * 0.9981)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  90.91% { transform: scale(calc(var(--photo-zoom) * 0.9985)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  93.18% { transform: scale(calc(var(--photo-zoom) * 0.9989)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  95.45% { transform: scale(calc(var(--photo-zoom) * 0.9993)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  97.73% { transform: scale(calc(var(--photo-zoom) * 0.9996)) translate(var(--photo-shift-x, 0%), var(--photo-shift-y, 0%)); }
  100% {
    /* Must match the resting transform exactly, or it jumps on removal. */
    transform:
      scale(calc(var(--photo-zoom) * var(--photo-push, 1)))
      translate(var(--photo-shift-x, 0%), calc(var(--photo-shift-y, 0%) + var(--photo-rise, 0%)));
  }
}
/* Chrome resolves on the tail of the pass-through, deliberately late: the
   image move is the star. The scroll cue comes last of all. */
.is-intro-running .strip,
.is-intro-running .social-bar {
  transition:
    opacity 800ms var(--ease-standard) 250ms,
    filter 800ms var(--ease-standard) 250ms;
}
.is-intro-running .hero-1__content {
  transition:
    opacity 550ms var(--ease-standard) 1150ms,
    transform 550ms var(--ease-standard) 1150ms;
}

/*
  The scroll cue arrives LAST, after the tagline rather than with it. It is
  an instruction, and an instruction only makes sense once there is
  something to act on. Its opacity multiplies with the content wrapper's,
  so it genuinely trails the rest of the type.
*/
.is-intro .hero-1__scroll-cue { opacity: 0; }
.is-intro-running .hero-1__scroll-cue {
  transition: opacity 450ms var(--ease-standard) 1450ms;
}

/*
  The ember accent rule draws itself, last — a quarter-second gesture that
  punctuates the sequence: the document has arrived. It is the page's single
  accent making its single deliberate appearance.
*/
.is-intro .hero-1__accent-rule { width: 0; }
.is-intro-running .hero-1__accent-rule {
  transition: width 320ms var(--ease-standard) 1650ms;
}

/*
  Reduced motion is handled in js/main.js by never adding .is-intro at all,
  so the page renders at its resting state on the first frame. base.css
  additionally neutralises transition durations globally. Belt and braces:
  nothing above can animate if the classes are never applied.
*/

/* The frosted background logo is no longer a separate element — it's each
   section panel's own fixed-attachment background (css/sections.css). The
   hero shows the SHARP mark below (.hero-1__mark-wrap), which blurs in on
   scroll and hands off, at the same viewport position, to that frosted
   panel background. */
.hero-1__mark-wrap {
  position: absolute;
  /*
    Geometry lives in css/tokens.css as --mark-w / --mark-x / --mark-y, and
    is now oversized and lifted well above the top edge so only the middle
    of the stroke crosses the frame — the mark reads as a fragment at
    architectural scale rather than as a logo placed on a picture.

    Editing those three values is safe: the frosted copy behind the sheets
    is DERIVED from them (see tokens.css), so the two can no longer drift
    apart the way they previously did on mobile.
  */
  top: var(--mark-y);
  left: var(--mark-x);
  width: var(--mark-w);
  max-width: none;
  z-index: 1;
  /*
    TOP-LEFT origin, declared on the base rule deliberately.

    Every intro figure in tokens.css (--intro-mark-s / -tx / -ty and their
    -s0 pair) is derived as "a point p maps to T + s·p", which is only true
    with the origin at the element's top-left corner. With the default
    50% 50% the same numbers put the opening frame 40vw down and right of
    where it belongs — the mark started in the bottom corner instead of
    centred.

    It previously sat on a paired .is-intro/.is-intro-running selector and
    was silently destroyed by an edit to the neighbouring rule. Here it is
    unconditional, and harmless at rest because the resting transform is
    none.
  */
  transform-origin: 0 0;
  /*
    Bone ink at --mark-ink alpha, in normal blend.

    The previous `screen` with a near-black #16171A paint was the reason
    the mark went invisible once the photograph went full bleed. Screen
    LIGHTENS by the paint value, so a near-black paint is close to a no-op:
    it lifted a bright pixel by about 5/255. It only ever read at all
    because the old split layout put half the hero over near-black void.

    Two blend modes were measured against a bright photo pixel before
    settling here — screen with bone paint, and normal with bone paint —
    and they land within 3/255 of each other, because once the paint is
    bright the two formulas converge. Normal is chosen for being the
    simpler of the two and, more importantly, for being trivially
    matchable by the frosted copy behind the sheets, which is a background
    image and cannot blend against the page at all.
  */
  color: var(--text);
  opacity: var(--mark-ink);
  pointer-events: none;
  will-change: filter;
  /*
    No CSS transition here on purpose. js/main.js writes this element's
    filter (blur) directly on (nearly) every scroll tick as the mark blurs
    in through the hero. A CSS transition would retarget that in-flight
    value each frame — the same wheel-input-freeze pattern fixed earlier
    for `scroll-behavior: smooth`. Scroll-linked values must apply
    instantly per frame, never ease via CSS. Position/scale are no longer
    animated (see js/main.js) — only the blur is, so the mark stays aligned
    with the fixed frosted panel background it hands off to.
  */
}
.hero-1__content {
  position: relative;
  z-index: 2;
  padding: 0 var(--edge) var(--space-6);
  max-width: var(--content-max);
}
.hero-1__tagline {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: var(--space-2);
  font-family: var(--font-mono);
  font-size: var(--step-meta);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  /*
    Raised from --text-muted to full --text. The muted grey was fine
    against flat graphite, but half this row now sits over the photograph's
    brightest area (the lit deck), where #7C7E82 fell well below a
    readable contrast ratio. The credential is the single most valuable
    string in the opening — it has to survive whatever is behind it.
  */
  color: var(--text);
  padding-bottom: var(--space-2);
  border-bottom: 1px solid var(--rule-strong);
}
.hero-1__accent-rule {
  width: 44px;
  height: 2px;
  background: var(--accent);
  margin-bottom: var(--space-2);
}

/* Scroll cue — a functional link (not decoration), doubling as the
   accent's one deliberate appearance in the otherwise-empty opening. */
.hero-1__scroll-cue {
  display: flex;
  align-items: center;
  gap: var(--space-1);
  width: fit-content;
  margin-bottom: var(--space-4);
  color: var(--accent);
}
.hero-1__scroll-cue:hover,
.hero-1__scroll-cue:focus-visible {
  color: var(--text);
}
.hero-1__scroll-label {
  font-family: var(--font-mono);
  font-size: var(--step-meta);
  letter-spacing: 0.14em;
  text-transform: uppercase;
}
.hero-1__scroll-chevrons {
  display: flex;
  flex-direction: column;
  align-items: center;
}
.hero-1__scroll-chevrons span {
  width: 8px;
  height: 8px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: translateY(0) rotate(45deg);
  animation: hero-scroll-cue-bob 1.8s var(--ease-standard) infinite;
}
.hero-1__scroll-chevrons span:last-child {
  opacity: 0.5;
  margin-top: -3px;
  animation-delay: 0.15s;
}
@keyframes hero-scroll-cue-bob {
  0%, 100% { transform: translateY(0) rotate(45deg); }
  50% { transform: translateY(4px) rotate(45deg); }
}

@media (max-width: 900px) {
  /*
    Mobile is framed as its own problem, not a squeezed desktop — and here
    the geometry inverts. A phone frame is TALLER than the 5:4 source, so
    object-fit:cover becomes height-driven: the full image height is shown
    and the sides are cropped instead. That has two consequences.

    First, object-position's Y has no effect at all (there is no vertical
    overflow left to distribute), which is why the vertical bias is a
    translateY instead — it lifts the frame off the empty ceiling and down
    onto the decks.

    Second, the desktop's wide reference crop is simply unreachable here:
    at this frame ratio, tightening to the reference's 55% of image height
    would leave only ~22% of its width, which is too narrow to hold both
    hands. So the phone crop commits to a different, honest framing — cap,
    face, and the hand on the mixer — rather than a broken imitation of the
    desktop one.
  */
  .hero-1__photo img {
    object-position: 66% 50%;
    --photo-zoom: 1.38;
    --photo-shift-y: -4%;
  }
  .hero-1__photo::after {
    background:
      linear-gradient(to top, var(--ground) 0%, rgba(12, 13, 15, 0.85) 12%, rgba(12, 13, 15, 0.3) 30%, rgba(12, 13, 15, 0) 52%);
  }

  /* The mark's mobile geometry lives with the rest of it in
     css/tokens.css, so the frosted copy behind the sheets tracks it
     automatically. Nothing to override here. */
}

/*
  The two mono strings in the tagline total ~324px at the smallest type
  step, against ~335px of usable width inside --edge on a 390px screen —
  so the flex row pushed "SIX-TIME WORLD CHAMPION" past the right edge,
  where .hero-1's overflow:hidden clipped it silently (no scrollbar, no
  visible symptom except a missing credential). Stacking is not a
  degradation here: giving the credential its own line reads better on a
  phone than cramming it opposite the role line.
*/
@media (max-width: 600px) {
  .hero-1__tagline {
    flex-direction: column;
    align-items: flex-start;
    gap: 0.4rem;
  }
}
