/* ==========================================================================
   Hero — the pinned scene, the lockup, and the identity shape inside it.
   The hero stands on --ground-warm, the site's Light 2 — the same ground the
   alternating case-study bands use, not a colour of its own.
   ========================================================================== */

/* --- The pinned scene ------------------------------------------------------
   .scene holds exactly two children — the hero and the About panel — and is
   the whole of the pin. Its height is theirs: 100svh of pinned hero plus
   100svh of cover, so ~200svh, which is also the scroll distance the effect
   gets. Nothing else may go inside it; everything after it scrolls normally.

   The negative margin lives here rather than on .hero. It has to: it pulls the
   scene up under the sticky header so the photo runs the full height of the
   viewport, and if it sat on the hero instead, the hero's box would start
   above the viewport top and `top: 0` would immediately shunt it back down by
   a header's height at scroll 0 — a visible jump on the very first frame. */
.scene {
  position: relative;
  margin-top: calc(-1 * var(--header-h));
}

/* The hero carries its own ink (--hero-ink), because it is the one section
   standing on a photo rather than a flat ground. Scoped here, so the rest of
   the site is untouched — and so swapping the art between light and dark is a
   token change, not a rewrite. */
.hero {
  color: var(--hero-ink);
  /* Light 2 rather than the page ground, so the hero reads as its own plate
     and the sections below it step back to --color-light. Reusing the
     existing token rather than a second near-white keeps the site at two
     light grounds, not three. */
  background: var(--ground-warm);
  /* Pinned: it holds at the top of the viewport for the length of .scene,
     which is exactly until About has finished covering it. z-index keeps it
     under the cover panel — sticky is positioned, so without this the hero
     would paint OVER the in-flow About and the panel would slide beneath it. */
  position: sticky;
  top: 0;
  z-index: 0;
  display: flex;
  flex-direction: column;
  min-height: 100svh;
  /* Top padding is exactly the header height, bottom is --sp-8, and that
     difference is what centres the lockup — not a guess.

     The headline sits between two 1fr rows, but the status row below it lives
     INSIDE the same grid, so it plus its row-gap (~40px) eats from the space
     under the headline while top padding only adds to the space above. The two
     balance when padTop − padBottom equals that bottom row. It used to be
     header + 40 against 32, which over-corrected and left the lockup 20px low.

     --header-h also does the job it always did: it is the floor that keeps
     content from sliding under the nav on a short viewport. */
  padding-block: var(--header-h) var(--sp-8);
  isolation: isolate;
  /* Still clips: the lockup blurs as it recedes, and without this the soft
     edge would bleed past the section. */
  overflow: hidden;
}

/* The panel that rises over the hero. min-height is load-bearing, not padding:
   the About copy is well under a screen tall, and a panel shorter than the
   viewport could never finish covering the hero — the next section would come
   into view first and the pin would end mid-transition. It also fixes the
   scene at ~200svh, and centres short copy in the panel instead of stranding
   it at the top. */
.scene__cover {
  position: relative;
  z-index: 1;
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

/* The lockup is promoted for the recede below. */
.js .hero__grid {
  will-change: filter;
}

/* --- The recede -----------------------------------------------------------
   As About rises over the hero, the lockup defocuses, so it reads as a layer
   being covered rather than a section scrolling away. Progress is tied to the
   scroll position, not to a duration: 0 when About's top edge is at the bottom
   of the viewport, 1 when it reaches the top.

   This used to drive two elements, the photo and the type. With the photo gone
   only the type is left, and the push-in went with it: scale was a property of
   the image layer, and scaling the headline instead would be a different
   effect rather than the same one ported across.

   Two implementations, one behaviour:
     1. CSS scroll-driven animation where supported — runs off the main thread,
        so the blur never competes with scrolling for frames.
     2. parallax.js sets the custom properties below from a rAF loop everywhere
        else, and stands down when the CSS version is live so the two never
        both drive them.

   .hero__grid is transparent, so blurring it cannot halo against the page the
   way blurring .hero itself would — a filter applies after the overflow clip. */

.hero__grid {
  filter: blur(var(--hero-recede-blur-text-now, 0px));
}

@media (prefers-reduced-motion: no-preference) {
  /* view() and timeline-scope are tested together because the timeline is
     declared on the cover panel and read by its SIBLING, the hero — which only
     works if a shared ancestor puts the name in scope. parallax.js tests the
     same pair, so a browser that has one but not the other still gets the
     effect from the fallback rather than nothing. */
  @supports (animation-timeline: view()) and (timeline-scope: --x) {
    .scene {
      timeline-scope: --about-cover;
    }

    .scene__cover {
      view-timeline: --about-cover block;
    }

    /* `entry 0%` is the panel's top edge at the bottom of the viewport;
       `contain 0%` is that same edge arriving at the top. Mixing the two range
       names is what keeps this exact for a panel of ANY height at or above one
       viewport — `entry 100%` would drift late as soon as the About copy grew
       past a screen, which it does on a narrow phone. */
    .hero__grid {
      animation-timeline: --about-cover;
      animation-range: entry 0% contain 0%;
      animation-fill-mode: both;
      /* Decelerating, so the hero gives way immediately and settles, rather
         than holding sharp and then dropping out under the panel's edge.
         This is easeOutCubic, spelled out rather than the `ease-out` keyword,
         because it has to be the same curve parallax.js applies — the keyword
         is a different, weaker bezier and the two paths would visibly diverge
         through the middle of the transition. */
      animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
    }

    .hero__grid { animation-name: hero-recede-text; }
  }
}

/* Lands at 85%, not 100% — the hero has finished receding a beat before the
   panel's edge reaches the top of the screen, so the last stretch of the pin is
   a clean cover rather than a change still resolving under it. parallax.js
   divides its progress by the same 0.85. */
@keyframes hero-recede-text {
  0% {
    filter: blur(0px);
  }
  85%,
  100% {
    filter: blur(var(--hero-recede-blur-text));
  }
}


/* --- Content -------------------------------------------------------------- */

/* Mobile: headline, then the status line directly beneath it; the scroll cue
   and the tag share the bottom row.
   Desktop (62rem+): the status line moves down into the footer row, sitting on
   the content line between the cue in the rail and the tag hard right.
   One element in one place — the grid moves it, nothing is duplicated. */
.hero__grid {
  flex: 1;
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  grid-template-rows: 1fr auto auto 1fr auto;
  grid-template-areas:
    ".      ."
    "head   head"
    "status status"
    ".      ."
    "cue    tag";
  column-gap: var(--sp-4);
  row-gap: var(--sp-5);
}

@media (min-width: 62rem) {
  .hero__grid {
    grid-template-columns: var(--rail) minmax(0, 1fr) auto;
    grid-template-rows: 1fr auto 1fr auto;
    grid-template-areas:
      ".   .       ."
      ".   head    head"
      ".   .       ."
      "cue status  tag";
    column-gap: 0;
  }
}

/* The lockup: Moran / Barkai — [shape] — Product / Designer. Three blocks on
   one row, each two lines tall, with the shape as the negative space between
   the name and the role.

   It wraps rather than shrinking: below about 62rem the three blocks cannot sit
   side by side at display size without the type collapsing, so the shape drops
   to its own row. That is the plain responsive behaviour of these assets — no
   bespoke mobile shape, per the brief. */
.hero__headline {
  grid-area: head;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  /* No column-gap — the shape carries its own margins, because the two sides
     are not the same size. See below. */
  column-gap: 0;
  row-gap: 0.2em;
  font-size: var(--fs-display);
  font-weight: var(--fw-medium);
  line-height: 0.94;
  letter-spacing: -0.042em;
  max-width: none;
}

.hero__headline .line {
  display: block;
}

/* The gaps either side of the shape, in the SVG's own units so they track the
   geometry rather than being eyeballed: at this size one SVG unit is
   (2 lines ÷ 147) em.

   They are NOT equal, on purpose, and the multiplier is bigger than it looks
   like it should be. TWO things inflate the right-hand gap, not one:

     1. The right edge is stepped — the wider top half holds "Product" further
        out than the bottom half holds "Designer".
     2. .hero__role is right-aligned, so the SHORTER word starts further from
        the shape. "Product" is narrower than "Designer", so the top line —
        the one the eye reads first — opens the widest gap on that side.

   Measured at 1440 with the old 1.75×: left was 20.1px at its tightest against
   29.9px on the right, and 23.7 against 32.0 on average. Roughly 35% more air
   on the right, exactly the imbalance this multiplier exists to cancel.

   2.6× matches the two tightest measurements (both ~29.9px) and brings the
   averages to within 1.5px. Re-measure if the role words ever change: the word
   LENGTHS are part of this, so "Product Designer" becoming anything else moves
   the right-hand gap and this number with it. */
.hero__headline {
  --ident-unit: calc(1.88em / var(--ident-h));
  --ident-gap: calc(var(--ident-unit) * 18);
  --ident-gap-lead: calc(var(--ident-gap) * 2.6);
}

/* Right-aligned, so "Product" and "Designer" end on one vertical line — the
   t and the r sit flush. That also produces the tuck for free: "Designer" is
   the wider word, so it reaches further left, back under the narrower bottom
   half of the shape. Indenting "Product" by hand did the opposite — it fixed
   the left edge and let the right edge fall wherever the word ended. */
.hero__role {
  text-align: right;
}

/* --- The identity shape --------------------------------------------------- */
/* Geometry only. The mask is the SVG, unmodified, so the same file defines this
   shape at every size it is ever used — inline here now, full-viewport behind
   About later. Vector, so it stays crisp at any scale.

   Nothing about the fill lives on this element; fills are stacked children. */
.ident {
  flex: none;
  margin-left: var(--ident-gap-lead);
  margin-right: var(--ident-gap);
  height: 1.88em;                      /* exactly two lines of the display type */
  aspect-ratio: var(--ident-w) / var(--ident-h);
  position: relative;
  isolation: isolate;

  -webkit-mask-image: var(--ident-shape);
  mask-image: var(--ident-shape);
  -webkit-mask-size: 100% 100%;
  mask-size: 100% 100%;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
}

/* One fill layer. They stack and cross-fade: every layer is present and
   positioned, only opacity separates them, so rotation later is a class
   swap rather than a DOM change.

   background-size: cover, because the fill art is a rectangle with bleed on
   all sides and no focal point — it is meant to be cropped by the shape rather
   than fitted to it. */
.ident__fill {
  position: absolute;
  inset: 0;
  background-image: var(--fill);
  background-size: cover;
  background-position: center;
  transform: scale(var(--ident-fill-zoom));
  background-repeat: no-repeat;
  opacity: 0;
  transition: opacity var(--ident-crossfade) var(--ease);
}

.ident__fill.is-current {
  opacity: 1;
}

/* Small screens take the narrower shape. Only two values change — the mask and
   the aspect — because everything else in the lockup is derived from them. The
   breakpoint is where the wide shape stops fitting on one line at 32px type
   (about 433px), rounded down for margin. */
@media (max-width: 30rem) {
  .ident {
    -webkit-mask-image: var(--ident-shape-sm);
    mask-image: var(--ident-shape-sm);
    aspect-ratio: 206 / var(--ident-h);
  }
}

@media (prefers-reduced-motion: reduce) {
  .ident__fill {
    transition: none;
  }
}

.hero__status {
  grid-area: status;
  align-self: end;
  font-family: var(--font-mono);
  font-size: var(--fs-caption);
  letter-spacing: 0.01em;
  color: var(--hero-ink);
}

.hero__tag {
  grid-area: tag;
  justify-self: end;
  align-self: end;
  font-family: var(--font-mono);
  font-size: var(--fs-label);
  letter-spacing: var(--ls-label);
  color: var(--hero-ink);
}

.scroll-cue {
  grid-area: cue;
  align-self: end;
  justify-self: start;
  display: inline-flex;
  align-items: center;
  gap: var(--sp-3);
  font-family: var(--font-mono);
  font-size: var(--fs-label);
  letter-spacing: var(--ls-label);
  color: var(--hero-ink);
  transition: opacity var(--dur-fast) var(--ease);
}

/* Accent is tuned for the light ground and disappears here, so the hover cue
   is a brightness shift instead. */
.scroll-cue:hover,
.scroll-cue:focus-visible {
  opacity: 0.7;
}

.scroll-cue svg {
  animation: cue-bob 2.8s var(--ease) infinite;
}

@keyframes cue-bob {
  0%,
  72%,
  100% {
    transform: translateY(0);
  }
  84% {
    transform: translateY(4px);
  }
}

/* --- Short viewports (landscape phones) ------------------------------------
   --fs-display scales on WIDTH alone, so a wide-but-short window gets a 72px
   headline in a 380px-tall viewport and the hero grows past one screen. That
   is a composition problem on its own — the scroll cue lands below the fold —
   but it also breaks the pin: .scene is hero + panel, so the hero stops
   sticking once the panel's height runs out, and a hero taller than the panel
   unsticks and starts sliding while it is still partly visible. Sizing the
   headline against the viewport height instead keeps the hero at exactly
   100svh, which is what makes the release land on full cover. */
@media (max-height: 36rem) {
  .hero {
    padding-block: var(--header-h) var(--sp-5);
  }

  .hero__headline {
    font-size: clamp(1.75rem, 11svh, 4rem);
  }
}

/* Just "Scroll", at every width — the arrow already says which way. The label
   stays wrapped in a single <span> because .scroll-cue is an inline-flex: bare
   text beside the icon would become its own flex item and the icon gap would
   open up inside the word. */

/* --- Entrance --------------------------------------------------------------
   Two beats, strictly sequential: the shape grows out of its own top-left
   corner and settles, then the lockup and the meta lines come in one after
   the next.

   Pure CSS, no JS and no state class. The hero only exists on the home page,
   so the animation cannot fire anywhere else, and with scripting off it still
   plays — there is nothing to gate.

   NOTHING HERE MOVES LAYOUT. Every element is in the DOM at its final size and
   position from the first frame; only opacity and transform are animated, and
   `animation-fill-mode: both` is what holds the from-state through the delay.
   No display, no visibility, no mounting. That is a hard requirement — the
   lockup is a flex row and the meta lines are grid items, so anything that
   changed an element's box would reflow the other two mid-animation.

   The spec listed three text beats — eyebrow, name, role. This hero has no
   eyebrow: "Product Designer" is not a label above the name, it is the right
   half of the lockup, with the shape as the negative space between them. So
   the beats follow the real reading order instead: name, role, status, then
   the footer row. Four beats at the same 150ms step.

   TODO — asset gating. The comment on this brief assumed the shape is pure
   CSS/SVG with nothing to wait for. It is not: the mask is an external SVG and
   the fill is a ~120KB WebP loaded as a background-image, which is fetched
   when the element first paints rather than up front. The planned
   image-cycling variant makes this worse, not better. Before that lands, the
   fill needs preloading and the sequence needs to start only once it has
   decoded — a `<link rel="preload" as="image">` plus starting the beats off
   that load, rather than at parse time as it does here. Not built: the brief
   asked for it flagged. */

/* Lifted from the motion prototype, which is the only place the feel was
   actually specified: a circular clip opening from the top-left corner while
   the shape drifts in from that corner and grows. Opacity is up by 8% so the
   thing reads as emerging rather than fading.

   The end radius is 100%, not the prototype's 75%. clip-path percentages
   resolve against sqrt((w^2 + h^2) / 2), so the value that just covers a
   square under-covers a 2.687:1 one — at 75% this shape would stay clipped at
   its own left and right ends forever. 100% clears it with margin. Both
   keyframes must use circle(): clip-path only interpolates between the same
   shape function, so an inset() endpoint would make this a hard cut. */
/* hero-shape-in and hero-line-in are defined in base.css, because the mobile
   menu reuses them and this file is only loaded by the home page. */


/* Beat 1. The mask scales with the element, so this is the shape itself
   growing into the gap the lockup is already holding open for it, not a
   full-size shape being wiped in. Transform only — the gap never changes
   width, so the name and the role either side of it do not move. */
.ident {
  animation: hero-shape-in var(--hero-in-shape-dur) var(--hero-in-shape-ease)
    var(--hero-in-shape-delay) both;
}

/* Beat 2. Each line starts --hero-in-line-step after the one before it, all
   of them offset past the end of beat 1 — so the shape is fully settled
   before the first line moves. */
.hero__name,
.hero__role,
.hero__status,
.scroll-cue,
.hero__tag {
  animation: hero-line-in var(--hero-in-line-dur) ease-out both;
}

/* The lockup is ONE beat — the name and the role are two halves of the same
   line with the shape between them, so staggering them read as the line
   assembling itself rather than arriving. */
.hero__name,
.hero__role {
  animation-delay: var(--hero-in-text-at);
}

/* --- The header joins the sequence ------------------------------------------
   Ahead of the bottom line, not after it: the eye goes to the top of the
   window once the lockup lands, so the chrome wants to already be there.

   Scoped to .site-header--over-hero, which only index.html carries — every
   other page's header is chrome above ordinary content and has no entrance to
   join.

   The three pieces are animated individually rather than through
   .site-header__inner, for two reasons, both of which bite:

     - A transform on an ancestor makes it the containing block for
       `position: fixed` descendants. The mobile menu panel is fixed inside
       .site-header__inner, so animating that element would break the panel's
       full-viewport positioning for the length of the entrance.
     - .site-nav IS that panel below 48rem, and it carries its own opacity for
       open/close. An entrance animation on it would win over the base rule
       (animations beat normal declarations), leaving a panel that can never
       hide again. So below the breakpoint the hamburger animates instead, and
       the nav itself is left alone. */
.site-header--over-hero .logomark {
  animation: hero-line-in var(--hero-in-line-dur) ease-out both;
  animation-delay: calc(var(--hero-in-text-at) + var(--hero-in-line-step));
}

@media (max-width: 47.999rem) {
  .js .site-header--over-hero .nav-toggle {
    animation: hero-line-in var(--hero-in-line-dur) ease-out both;
    animation-delay: calc(var(--hero-in-text-at) + var(--hero-in-line-step));
  }
}

@media (min-width: 48rem) {
  .site-header--over-hero .site-nav {
    animation: hero-line-in var(--hero-in-line-dur) ease-out both;
    animation-delay: calc(var(--hero-in-text-at) + var(--hero-in-line-step));
  }
}

/* Last: the status line, the cue and the tag as one beat, a breath behind the
   header rather than a step after it. */
.hero__status,
.scroll-cue,
.hero__tag {
  animation-delay: calc(
    var(--hero-in-text-at) + var(--hero-in-line-step) + var(--hero-in-tail-step));
}

/* Straight to the settled state. `animation: none` is enough precisely because
   the from-state only ever existed inside the keyframes. */
@media (prefers-reduced-motion: reduce) {
  .ident,
  .hero__name,
  .hero__role,
  .hero__status,
  .scroll-cue,
  .hero__tag,
  .site-header--over-hero .logomark,
  .site-header--over-hero .nav-toggle,
  .site-header--over-hero .site-nav {
    animation: none;
  }
}

/* --- About: scroll-driven ground -------------------------------------------
   The band opens at --about-bg-from (a coral close to the hero's palette) and
   ramps to --ground-ink as the section covers the hero.

   This reuses the timeline the pinned scene already declares — .scene__cover
   IS the About section, and it already publishes `view-timeline: --about-cover`
   for the hero recede to read. So the ground and the recede are driven by one
   scroll range, in step by construction, rather than by a second system that
   could disagree with the first.

   NOTE: the brief describes this as "the mechanism already used for the hero
   identity-shape morph into this section's background". That morph was never
   built — it is still on the list. What exists is the hero RECEDE, which is
   what this hooks into.

   The colour lands at 50% of the range, not 100%, so the section finishes
   arriving before it finishes scrolling — which is what makes it read as the
   panel settling rather than as a gradient tied to the scrollbar. */

@supports (animation-timeline: view()) and (timeline-scope: --x) {
  @media (prefers-reduced-motion: no-preference) {
    .scene__cover {
      animation-name: about-ground;
      animation-timeline: --about-cover;
      animation-range: entry 0% contain 0%;
      animation-fill-mode: both;
      animation-timing-function: linear;
    }

    /* Gated on .rail, not on .container: parallax.js owns the container's
       transform, and animating transform on the same element would make the
       two fight. .rail carries the label, the body and the CTA — everything
       in the section. */
    .scene__cover .rail {
      animation-name: about-content-in;
      animation-timeline: --about-cover;
      animation-range: entry 0% contain 0%;
      animation-fill-mode: both;
      animation-timing-function: linear;
    }
  }
}

/* The ramp is linear in sRGB, which is what the gate below was measured
   against — interpolating in oklab would put different colours at the same
   progress and move the crossover. */
@keyframes about-ground {
  0% { background-color: var(--about-bg-from); }
  50%,
  100% { background-color: var(--ground-ink); }
}

/* Content holds at 0 until the ground is dark enough to carry it.
   30% is the measured crossover — the first point where BOTH --ink-on-dark
   and --ink-on-dark-muted clear 4.5:1 against the interpolated colour
   (6.28 and 4.69 there; at 25% the muted step is still 4.42 and fails).
   38% is the crossover plus margin for rendering differences, and is where
   the fade starts rather than where it finishes. */
@keyframes about-content-in {
  0%,
  38% {
    opacity: 0;
    transform: translateY(12px);
  }
  56%,
  100% {
    opacity: 1;
    transform: none;
  }
}
