/* ==========================================================================
   hero.module — PHASE-1.md task 1.6

   Translated from the hero in docs/comps/design-foundation.html §5: an
   asymmetric named-line grid where the copy sits inside the container column
   and the media bleeds to the viewport edge. No cards, no shadow.

   Tokens only, no raw hex, no !important, mobile-first (§4.7). The surface and
   the vertical padding come from base.css's .vz-section--bg-* / --pad-*
   modifier classes and are not redefined here; the eyebrow is .vz-kicker, the
   headline .vz-display, the subhead .vz-lead and both actions .vz-btn — all
   base.css primitives, so this file owns composition and nothing else.
   ========================================================================== */

/* --------------------------------------------------------------------------
   1. The grid

   Five named lines reproduce .vz-container's geometry as grid tracks, so a
   child can opt out of the container and run to the viewport edge without the
   module ever nesting a full-bleed hack inside a centred box.

   The content tracks total (--vz-maxw - 2 * --vz-gutter), NOT --vz-maxw. That
   is the one place this diverges from the comp, and it is not cosmetic:
   .vz-container is border-box, so its *content* is maxw minus two gutters. The
   comp's tracks total maxw, which starts the headline one whole gutter (up to
   40px) to the left of every .vz-container section below it — including the
   stats bar, whose first figure is supposed to line up with the headline.
   Measured at 1280 and 1440, not reasoned about.

   Below the content tracks' own width the outer minmax() floors take over and
   both sides fall back to a single gutter, which is exactly what .vz-container
   does. So the two agree at every viewport, not just wide ones.

   THE SPLIT IS 46/54, AND IT IS A PERCENTAGE OF THE GRID, NOT A LENGTH. Two
   equal halves is the composition with no opinion in it: the eye reads a hero
   cut down the middle as two panels of equal rank, which is the one thing this
   fold must not say — the photograph is the evidence, the copy is the claim,
   and the claim leads. Giving the media the larger track is what makes the
   asymmetry legible rather than merely present.

   Written as min(percentage-of-this-grid, the-capped-content-width) so the
   ratio holds at EVERY width rather than only above --vz-maxw. Two fixed
   lengths do not do that: grid distributes free space to length tracks equally
   until each reaches its growth limit, so a 534px and a 626px track in 944px of
   space both come out at 472 — equal halves again, at exactly the desktop
   widths most visitors are on. A percentage track resolves against the grid
   container's inline size and stays proportional the whole way up.

   `100%` and not `100vw`: the section's own box excludes the scrollbar, the
   viewport unit does not. A vw-based content width is 15px too wide the moment
   a scrollbar exists, and the outer tracks — already clamped to their gutter
   floor — have nowhere to absorb it, so the page gains a horizontal scrollbar
   on precisely the pages that are long enough to scroll.
   -------------------------------------------------------------------------- */
.vz-hero {
  --vz-hero-content: min(
    calc(100% - 2 * var(--vz-gutter)),
    calc(var(--vz-maxw) - 2 * var(--vz-gutter))
  );
  --vz-hero-copy-w:  calc(var(--vz-hero-content) * 0.46);
  --vz-hero-media-w: calc(var(--vz-hero-content) * 0.54);
  display: grid;
  row-gap: var(--vz-pad-s);
  grid-template-columns:
    [full-start] minmax(var(--vz-gutter), 1fr)
    [content-start] minmax(0, var(--vz-hero-copy-w))
    [mid] minmax(0, var(--vz-hero-media-w))
    [content-end] minmax(var(--vz-gutter), 1fr)
    [full-end];
  /* Containing block for the two light layers in §1a, and the clip that keeps
     them inside the band: each is 6% oversized at the far end of its drift, and
     on a full-bleed section that is a horizontal scrollbar. */
  overflow: clip;
  isolation: isolate;
}

/* The section's own vertical padding, re-declared as a custom property so the
   media can cancel exactly as much of it as it needs to sit flush on the
   bottom seam (§3 of this file). Three one-line mappings rather than
   overriding .vz-section--pad-*, which §4.6 makes the shared owner of that
   value — the editor's choice stays authoritative either way. */
.vz-hero.vz-section--pad-s { --vz-hero-pad-block: var(--vz-pad-s); }
.vz-hero.vz-section--pad-m { --vz-hero-pad-block: var(--vz-pad-m); }
.vz-hero.vz-section--pad-l { --vz-hero-pad-block: var(--vz-pad-l); }

/* Nothing to show at all — every field blank, or a fields.json the API
   rejected, which leaves every module.* lookup empty (§6 CLI). Without this
   the hero would still paint its surface and its vertical padding as a band of
   colour containing nothing. Same guard and same reason as
   .vz-stats-bar:not(:has(.vz-stat)); :empty cannot be used because the HubL
   branches leave whitespace behind. */
.vz-hero:not(:has(.vz-hero__copy > *)):not(:has(.vz-hero__media, .vz-hero__bg, .vz-hero__form)) { display: none; }

/* --------------------------------------------------------------------------
   1a. The lit surface — .vz-section--bg-wash, and the two light layers

   THE ONE SURFACE THIS MODULE OWNS. base.css §3 defines the four flat
   backgrounds every module shares; `wash` is defined here instead, because it
   is not a colour — it is a fold. Three brand-coloured glows placed for this
   composition, drifting on two slow loops, under a veil that hands the top of
   the band to the chrome. A proof band or a card grid wearing the same
   treatment would be a page with two focal points,
   so the value exists only in this module's fields.json and only this module's
   CSS knows how to paint it.

   THE PALETTE IS THE PALETTE. Blue upper-left behind the headline, coral on
   the seam where the copy meets the photograph, lime low under the actions —
   the brand's own three colours, at the strengths theme.css sets, over the
   cream surface the brand already ships. Nothing here introduces a hue the
   theme did not already have, which is why it reads as VizSense lit from
   somewhere rather than as a gradient applied to VizSense.

   IT IS THE WHOLE BAND'S LIGHT, NOT THE COPY COLUMN'S. The photograph is held
   off the section's top and bottom edges (§4), so above 64em the surface is
   visible down the left of the page AND in the strips over and under the
   picture — which is why the three lights are spread across the full width
   rather than banked into the copy column: blue on the left behind the
   headline, coral high and right so the top strip stays warm and carries the
   colour past the picture's leading edge, lime low so the band under it lifts.
   The photograph then floats ON the light instead of ending it.

   Below 64em the same three positions land in a single column and read as a
   light source above and left of the headline, with the lime moved up to sit
   under the actions rather than behind the image (§4). One stack, two
   compositions.

   The `background-image` sits on a pseudo-element rather than on the section
   because the section's background-color is base.css's job on four of the five
   surfaces, and because a layer of its own is what the load fade and the
   reduced-motion branch have to switch.
   -------------------------------------------------------------------------- */
.vz-hero.vz-section--bg-wash {
  background-color: var(--vz-color-surface);
  color: var(--vz-color-ink);
}

/* TWO LAYERS, NOT ONE, AND THE REASON IS THE MOTION. The light breathes: each
   layer drifts on its own slow loop, and lights that move together read as one
   texture sliding around, which is a background effect. Split across the
   element's two pseudo-elements — blue and lime on ::before, coral on ::after —
   they pass each other instead, and the colour where they overlap changes as
   they go. That is the whole trick, and it costs no markup.

   Both are absolutely positioned, so neither is a grid item: a pseudo-element
   on a grid container otherwise takes a track of its own and pushes the copy
   across the fold.

   THE VEIL RIDES ON THE FIRST LAYER. A background-image list paints its first
   layer on top, so the white-to-transparent fade over the top quarter is
   declared first and sits above the glows: the frosted bar comes down onto a
   clean surface and the colour begins under it rather than at the top edge of
   the screen. It is --vz-atm-veil rather than white so the dark surfaces can
   fade from their own colour instead of blooming white under the chrome.

   The fade is on the STILL layer's stack, deliberately. Put it on a drifting
   one and the blend under the bar would slide with it, which is the one place
   on the page that has to stay put. */
.vz-hero:is(.vz-section--bg-wash, .vz-section--bg-brand, .vz-section--bg-ink)::before,
.vz-hero:is(.vz-section--bg-wash, .vz-section--bg-brand, .vz-section--bg-ink)::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
}

.vz-hero:is(.vz-section--bg-wash, .vz-section--bg-brand, .vz-section--bg-ink)::before {
  background-image:
    linear-gradient(to bottom, var(--vz-atm-veil) 0%, transparent 25%),
    radial-gradient(58rem 46rem at -6% 6%, var(--vz-atm-blue), transparent 72%),
    radial-gradient(52rem 40rem at var(--vz-atm-lime-x, 18%) var(--vz-atm-lime-y, 66%), var(--vz-atm-lime), transparent 68%);
}

.vz-hero:is(.vz-section--bg-wash, .vz-section--bg-brand, .vz-section--bg-ink)::after {
  background-image:
    radial-gradient(48rem 42rem at 68% -14%, var(--vz-atm-coral), transparent 70%);
}

/* The dark surfaces re-declare the three tokens and inherit the placement.
   Deep navy takes light differently from cream: the blue all but vanishes at
   20% over a surface that is already that hue, while coral and lime read as
   twice as strong, so the balance inverts rather than scales. Both surfaces
   keep their base.css background-color; only the light on top of it changes. */
.vz-hero:is(.vz-section--bg-brand, .vz-section--bg-ink) {
  --vz-atm-blue:  color-mix(in oklab, var(--vz-color-primary) 20%, transparent);
  --vz-atm-coral: color-mix(in oklab, var(--vz-color-accent-warm) 9%, transparent);
  --vz-atm-lime:  color-mix(in oklab, var(--vz-color-accent) 8%, transparent);
  --vz-atm-veil:  color-mix(in oklab, var(--vz-color-ink-deep) 55%, transparent);
}

/* Brand blue takes less light than the navy does, and the reason is contrast
   arithmetic rather than taste. #2844a6 is a mid-luminance surface: the lime
   eyebrow and the lime ghost link clear 4.5:1 on it with little to spare
   (6.6:1), so every point of luminance the glows add comes straight off that
   margin. The navy starts at 13.6:1 and can afford four times as much light.
   Measured on the composited worst case in hero-harness.mjs, both surfaces. */
.vz-hero.vz-section--bg-brand {
  --vz-atm-blue:  color-mix(in oklab, var(--vz-color-primary) 11%, transparent);
  --vz-atm-coral: color-mix(in oklab, var(--vz-color-accent-warm) 7%, transparent);
  --vz-atm-lime:  color-mix(in oklab, var(--vz-color-accent) 6%, transparent);
  --vz-atm-veil:  color-mix(in oklab, var(--vz-color-primary-deep) 55%, transparent);
}

/* THE LIGHT GOES OUT WHERE A PHOTOGRAPH TAKES THE SURFACE (CR-11). `wash` is
   still a legal value under layout="centered_bg" — an editor picking a layout
   does not go back and change a Styles-tab field, and CR-11 was asked NOT to
   add a sixth `background` value for the case — but three brand-coloured glows
   painted OVER a photograph is a colour cast on someone's picture, not a lit
   surface, and it lands between the image and the type whose contrast this
   composition exists to guarantee. So the layers are removed rather than
   dimmed, and they are removed on [data-vz-surface-dark] rather than on the
   layout class: that attribute means "there is actually a photograph here",
   which is the condition, whereas the layout class is also true of a
   centered_bg hero with no media picked — and that one is an ordinary
   coloured band that should keep its light.

   `content: none` rather than `display: none`, so the pseudo-elements are never
   generated at all: a suppressed layer that still exists is still a box the
   compositor carries, and §6 has the measurement for what an idle animated
   layer costs. It also makes the suppression legible to a probe, which is how
   hero-light-check.mjs asserts it. */
.vz-hero[data-vz-surface-dark]::before,
.vz-hero[data-vz-surface-dark]::after { content: none; }

/* Content rides above both layers. */
.vz-hero__copy,
.vz-hero__media,
.vz-hero__form {
  position: relative;
  z-index: 1;
}

/* --------------------------------------------------------------------------
   1b. Under the chrome

   THE BAR IS GLASS, SO GIVE IT SOMETHING TO BE GLASS OVER. header.module's bar
   is translucent and blurred (its §4a) because the page is meant to scroll
   underneath it — but at the top of the document it was translucent over the
   flow space the spacer reserves, which is the body background and nothing
   else. On a white hero that is invisible. On a lit one it is a solid white
   band sitting above the design, which is the one thing the wash cannot
   survive: the fold's light stops a header's height short of the top of the
   screen.

   So the first section of the page starts at the top of the VIEWPORT, not
   below the chrome. It pulls itself up by exactly the bar's height and pads its
   own content back down by the same amount, which leaves every line of copy
   where it was and puts the surface behind the glass.

   THREE THINGS THIS DELIBERATELY IS NOT.

   It is not a change to the spacer. The spacer also reserves the announcement
   bar, which is opaque content that scrolls away, and a hero starting behind
   THAT would have its eyebrow hidden until the visitor scrolled. Pulling up by
   --vz-chrome-h alone lands the section's top edge exactly under the
   announcement and behind the frosted bar, with or without one.

   It is not unconditional. --vz-chrome-h is declared in header.module's CSS and
   nowhere else, so a page or a harness without the chrome resolves it to 0px
   through the fallback and this rule does nothing at all — no negative margin
   hanging off the top of a page that has no bar to hide under.

   And it is not every hero. `:first-child` is the whole gate: a hero that is
   not the page's opening section is a hero with something above it, and pulling
   it up would slide it over that section's last line. If the selector ever
   stops matching — the portal wrapping the area differently, an editor dragging
   another module above it — the hero simply sits below the chrome as it always
   did. The failure mode is the old design, not a broken one.
   -------------------------------------------------------------------------- */
.dnd-section:first-child .vz-hero {
  margin-block-start: calc(-1 * var(--vz-chrome-h, 0px));
  /* NOT chrome + a full section padding. That was the first version and it read
     as a hole above the eyebrow: the bar's own height is already open space —
     a strip of the surface with four small things sitting on it — so counting
     the section's full padding underneath it stacks two kinds of air on top of
     each other. Half of it, and the copy sits where the eye expects the fold to
     start. The bottom padding is untouched: nothing overlaps it.

     The floor is not arbitrary. The frosted bar does not end on a line — its
     blur stack hangs --vz-glass-tail (1.75rem) BELOW the bar and dissolves
     there, which is the only reason a translucent header can go without a rule
     under it. Half of the compact padding on a phone is 32px, which would put
     the eyebrow 4px past the end of that dissolve, reading as a line of type
     caught in the blur. --vz-space-xl clears it at every width and is still well
     under what a full section padding was adding. */
  padding-block-start: calc(
    var(--vz-chrome-h, 0px) + max(var(--vz-space-xl), var(--vz-hero-pad-block, 0px) * 0.5)
  );
}

/* --------------------------------------------------------------------------
   2. Copy column
   -------------------------------------------------------------------------- */
.vz-hero__copy {
  grid-column: content-start / content-end;
  /* A grid item's default min-width is min-content, so a track capped at
     minmax(0, the copy width) still gets pushed wider by an unbreakable word.
     Both halves of the fix are needed; this is the one that bounds the box. */
  min-inline-size: 0;
}

/* ...and this is the one that bounds the text. Measured, not assumed: a
   29-character unbroken headline widened the page by 286px at 360 and 41px at
   834 before this rule — a horizontal scrollbar on the whole document, from
   one field an editor is free to type into. Same guard, same reason, as
   .vz-stat__value's. */
.vz-hero .vz-hero__eyebrow,
.vz-hero .vz-hero__heading,
.vz-hero .vz-hero__subtext { overflow-wrap: break-word; }

/* With no image the copy would otherwise run the full 1160px content width at
   a 90px display size, which is neither readable nor what the composition is.
   Capping it keeps the hero asymmetric — deliberate whitespace on the right
   rather than a missing column — and is a little wider than the with-media
   case so a copy-only hero still reads as its own composition. */
.vz-hero:not(:has(.vz-hero__media)) .vz-hero__copy { max-inline-size: 42rem; }

/* Margins are declared under .vz-hero (0,2,0) rather than on the bare
   modifier: .vz-display already sets `margin: 0` at (0,1,0) and the
   boilerplate's theme-overrides.css sets margins on bare `p`. Doubling the
   class settles both whichever order the portal emits the stylesheets in —
   the same reason footer.module doubles its image selectors. */
.vz-hero .vz-hero__eyebrow { margin-block: 0 var(--vz-space-m); }
.vz-hero .vz-hero__heading { margin-block: 0 var(--vz-space-m); }
.vz-hero .vz-hero__subtext { margin-block: 0 var(--vz-space-l); }

/* THE H1 GETS THE STEP ABOVE THE SCALE'S TOP. .vz-display is step 4, which is
   also what every h2 on the page reaches for at its own top end — so the fold's
   headline and a mid-page section heading were within a few pixels of each
   other at the widths where it matters most. The page's one h1 outranking every
   other line on it is the cheapest hierarchy there is, and it was not being
   claimed. Step 5 (theme.css) is 1.24x step 4 at the cap.

   Tracking tightens by a hundredth as the size grows, which is what keeps 90px
   from reading as loose — and stops at -0.03em, comfortably off the -0.04em
   floor where letters start to touch. */
.vz-hero .vz-hero__heading {
  font-size: var(--vz-step-5);
  letter-spacing: -0.03em;
}

/* TWO SMALLER h1s, FOR THE HEADLINE THAT ISN'T THREE WORDS. Step 5 is composed
   for the two-to-six-word headline the field's help text asks for; a longer one
   at 90px fills the copy column with four lines and pushes the actions off the
   fold. The Headline size field offers a way down from that without the editor
   rewriting the headline — the only other lever they had.

   THE THREE SIZES ARE ANCHORED TO THE SCALE, NOT INVENTED AROUND IT:

     l  step 5              44 -> 90px   the rule above; the designed size
     m  half a step down    40 -> 81px   the clamp below
     s  step 4              40 -> 72px   .vz-display's own size

   Half a step is exact rather than approximate: the scale's ratio is 1.24 per
   step, so 1.11 is its square root and lands `m` precisely between steps 4 and
   5. `s` needs no new number at all — it is the display step, which is what
   .vz-display already sets and what this module overrode to reach step 5 in the
   first place. So the smallest option is the h1 with the hero's own opinion
   removed, not a fourth clamp to keep in sync.

   ALL THREE STILL OUTRANK EVERY HEADING BELOW THEM, which is the only reason
   step 5 exists and the constraint that decides how far down this ramp is
   allowed to go. Measured against the library rather than assumed: every h2 in
   every module is .vz-h2, base.css sets that to step 3, and step 3 caps at
   2.75rem — 44px. No module overrides it. So `s` at 72px is still 1.64x the
   largest section heading on the page, and the outline is legible at every one
   of the three.

   `m` holds step 5's growth window — 296px to 1296px of viewport, where step
   5's own clamp starts and stops moving — so it tracks the default as the
   screen changes instead of crossing it somewhere in the middle. `s` keeps step
   4's window, which is the scale's and is close enough that the three never
   reorder. Measured at 360/390/768/1024/1280/1440:

     l   46.96  48.34  65.73  77.50  89.28  90.00
     m   42.60  43.83  59.33  69.82  80.32  81.00
     s   41.20  42.10  53.44  61.12  68.80  72.00

   THE THREE CONVERGE ON A PHONE AND THAT IS CORRECT, not a clamp that needs
   fixing. At 360 the whole ramp spans 5.7px and `m` and `s` are 1.4px apart —
   every option is at or near its floor because a 360px column has no room to
   express the difference, and a headline that fits at 47px does not need a
   choice about it. The control earns its keep from about 768 up, where the
   spread is 12px and grows: that is where a long headline was taking a fourth
   line. An editor who picks Small on a phone preview and sees almost nothing
   change is seeing the scale work, and the help text says up-to sizes for the
   same reason.

   Tracking tightens by half a hundredth as the ramp climbs — -0.02em, -0.025em,
   -0.03em — and the bottom of that progression is exactly
   --vz-tracking-display, so `s` restates the token rather than a number.

   Both rules are (0,2,0), identical to the rule they override, so they win on
   source order and must stay below it. */
.vz-hero .vz-hero__heading--m {
  font-size: clamp(2.5rem, 1.74rem + 4.10vw, 5.0625rem);
  letter-spacing: -0.025em;
}
.vz-hero .vz-hero__heading--s {
  font-size: var(--vz-step-4);
  letter-spacing: var(--vz-tracking-display);
}

/* THE SUBHEAD IS TWO POINTS LARGER THAN .vz-lead, AND IT IS WRITTEN AS POINTS.
   The scale is fixed in theme.css and step 1 is what every other lead on the
   site is set in, so the token stays the source of the size and the hero adds
   to it — a second hand-written clamp here would silently stop tracking the
   scale the next time it moves. calc() applies the addition to the resolved
   value, so both ends of the clamp travel together: 21px to 23.67px at the
   floor, 24px to 26.67px at the cap.

   IT STAYS AN ADDITION RATHER THAN BECOMING STEP 2, and the two are now close
   enough that the distinction has to be deliberate. Step 2 is 26px to 32px —
   its floor is already below where this lands at the cap, so at wide widths
   `+2pt` and step 2 differ by more than 5px, and at narrow ones step 2 would
   overtake this by 2px. Reaching for the next step would also make the hero's
   subhead a heading-scale size while every other lead on the site stays at step
   1, which is a different claim than "the fold's intro reads slightly larger".

   The ceiling on this is the headline, not the scale: the gap between the two is
   what the fold's hierarchy is made of. Measured, it is 3.37x at 1440 (90 over
   26.67) and 1.97x at 360 (47 over 23.83) — the phone end is the tight one and
   is where a third point would start to cost something, because both clamps are
   near their floors there while only one of them stops growing. That is the
   number to re-measure before this is nudged again, not the wide one.

   BALANCED, NOT PRETTY. `pretty` only rescues the last line from an orphan;
   `balance` evens every line, which is what a two-sentence lead under a
   headline composed word by word needs — a ragged right edge below that reads
   as an accident rather than as setting. Both are hints: a browser without
   support, and Chromium past its own line-count ceiling on a narrow phone, fall
   back to normal wrapping and lose nothing. Inherited, so the editor's own <p>
   children take it without being selected — and the cap that decides how much
   there is to balance is .vz-lead's 54ch, which is unchanged. */
.vz-hero .vz-hero__subtext {
  font-size: calc(var(--vz-step-1) + 2pt);
  text-wrap: balance;
}

/* The subhead steps back so the headline can step forward: contrast between
   levels is what hierarchy is, and two paragraphs at full ink strength under a
   90px headline is a fold with two voices. Light surfaces only — on brand and
   ink, base.css already sets --vz-color-on-dark and a muted ink would be
   unreadable there.

   A hero-local mix rather than --vz-color-ink-muted, and the difference is the
   only reason this is not one word: the shared token is ink at 72%, which is
   6.23:1 on the cream base but 3.88:1 against the lit surface's composited
   worst case — under the 4.5:1 floor for text this size. At 84% it reads 9.1:1
   flat and 5.66:1 lit: the step back the hierarchy needs, clearing the floor on
   the surface it actually sits on. Every number measured in hero-harness.mjs,
   on the wash, against the light as it currently ships — re-measured after the
   glows were strengthened, because a contrast figure quoted from an earlier
   pass is worse than no figure at all. */
.vz-hero:is(.vz-section--bg-white, .vz-section--bg-surface, .vz-section--bg-wash) .vz-hero__subtext {
  color: color-mix(in oklab, var(--vz-color-ink) 84%, var(--vz-color-white));
}

/* subtext is a richtext field, so the editor's own <p> / <ul> come through.
   Collapse only the outer edges; interior rhythm stays theirs. */
.vz-hero .vz-hero__subtext > :first-child { margin-block-start: 0; }
.vz-hero .vz-hero__subtext > :last-child  { margin-block-end: 0; }

.vz-hero__actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--vz-space-m) var(--vz-space-l);
}

/* --------------------------------------------------------------------------
   3. Media column

   On a phone the image runs edge to edge and finishes flush against whatever
   section follows — the seam the comp is built on, and the reason the stats
   bar reads as continuous with the hero rather than as a box under it.

   It gets there with a negative bottom margin equal to the section's own
   padding, not by zeroing that padding: a grid item's negative margin shortens
   its row while the box still paints, so the image's bottom edge lands exactly
   on the section's, whichever padding the editor picked, and the section's
   total height is unchanged.

   overflow: clip is load-bearing, not tidiness — the reveal in §5 scales the
   image to 1.03, which on a full-bleed element is 1.5% of the viewport past
   each edge and a horizontal scrollbar on every page load. base.css §7 states
   the rule; this is the module that needs it.
   -------------------------------------------------------------------------- */
.vz-hero__media {
  grid-column: full-start / full-end;
  overflow: clip;
  margin-block-end: calc(-1 * var(--vz-hero-pad-block, 0px));
}

/* THE FRAME IS WHERE §6's BLEND GROUP ENDS, and it is the reason the clip and
   the radius stay on .vz-hero__media rather than moving down here. A blended
   layer is alpha-composited: where the source and the backdrop are BOTH
   partially transparent — every antialiased pixel of a rounded corner — the
   `as * (1 - ab) * Cs` term survives, and for a dark multiplied copy that is a
   rim drawn along the curve. Isolating the group on a square, unclipped element
   makes the image and both copies opaque across all of it, so the blend
   finishes clean and the parent's rounded clip cuts an already-opaque result.
   One antialiased edge instead of two.

   It must NOT gain an overflow of its own: §5's drift runs the image 5% larger
   than this box, which is what guarantees the frame is covered at both ends of
   the travel, and clipping here would take that away from the copies too. */
.vz-hero__frame {
  position: relative;
  isolation: isolate;
}

.vz-hero .vz-hero__media img {
  display: block;
  inline-size: 100%;
  aspect-ratio: 3 / 2;
  object-fit: cover;
}

/* --------------------------------------------------------------------------
   4. 64em: the copy field beside a full-height media slab

   The separation between copy and media is padding on the copy column, NOT
   column-gap on the grid. A gap is inserted between every pair of the four
   tracks and comes out of the 1fr outer tracks first, which pulls the content
   tracks off .vz-container's alignment by tens of pixels — the exact
   misalignment §1 exists to prevent. Padding on the item leaves the track
   maths untouched.

   64em rather than 62em (the stats bar's single-row breakpoint): the copy
   column here is 46% of the content width, and below 1024px a display headline
   in ~430px next to a portrait image leaves the subhead at about 30 characters
   a line.
   -------------------------------------------------------------------------- */
@media (min-width: 64em) {
  .vz-hero {
    align-items: center;
    row-gap: 0;
  }

  /* The lime moves with the layout. On a phone the copy ends about two thirds
     down and the photograph owns the rest, so the glow sits under the actions
     where it can be seen; across two columns it drops below the picture and
     spreads, lifting the strip between the image's lower edge and the seam with
     the proof band. Two numbers move, not the stack. */
  .vz-hero {
    --vz-atm-lime-x: 34%;
    --vz-atm-lime-y: 112%;
  }

  .vz-hero:has(.vz-hero__media) .vz-hero__copy {
    grid-column: content-start / mid;
    padding-inline-end: clamp(var(--vz-space-l), 5vw, var(--vz-space-3xl));
  }

  .vz-hero__media {
    grid-column: mid / full-end;
    margin-block-end: 0;
  }

  /* Radius on the leading corners only: the trailing edge is the viewport.

     It sits on the CLIP BOX, not on the image, and that is not a preference.
     `overflow: clip` clips to the rounded shape, so the corners are correct
     whatever the image inside is doing — and §5's drift means the image is
     permanently 5% larger than this box, which put its own rounded corners
     outside the clip and squared them off. Measured: a 779px image with 12px
     corners inside a 720px square-cornered box. On the box the two are the same
     shape at rest and stay correct while the image moves. */
  .vz-hero__media {
    border-start-start-radius: var(--vz-radius);
    border-end-start-radius: var(--vz-radius);
  }

  /* Portrait, capped in height so the hero stays one screenful. The cap wins
     over the ratio above ~1024px, so the crop widens gradually instead of the
     image growing to 900px tall at 1440 — object-fit: cover absorbs it.

     A full-height slab flush to both section edges was tried here and taken
     back out: it closed the seam with the proof band, but it also traded the
     photograph's own shape for a face of the fold, and the lit surface it stood
     on could only be seen down one side of the page. The picture reads better
     as a picture — held off the top and bottom edges, its leading corners
     rounded, floating on the light rather than replacing half of it. */
  .vz-hero .vz-hero__media img {
    aspect-ratio: 4 / 5;
    max-block-size: 40rem;
  }
}

/* --------------------------------------------------------------------------
   4a. layout="split" — an even 50/50, and the picture stays on the page

   THE RATIO ALONE WOULD NOT BE A SECOND LAYOUT. §1 explains why media_right is
   46/54 rather than 50/50: the copy is the claim and it leads. Flipping that to
   an even split is a real difference in rank, but at a glance it is eight
   percent of a column — an editor picking between two entries in a dropdown
   would not be able to tell which one they had chosen.

   What makes them two compositions is where the picture ENDS. media_right's
   photograph runs past the container and off the right edge of the screen: the
   fold has no right-hand boundary, which is the whole reason the section is a
   named-line grid instead of a container with two columns in it. `split` holds
   the picture inside the page — both columns end on the container's own line,
   the media carries --vz-radius on all four corners rather than two, and the
   fold reads as a spread rather than as a band running off the edge.

   And the ratio is still a PERCENTAGE of the grid, for §1's measured reason:
   two fixed lengths are distributed equally by grid until each reaches its
   growth limit, so 46/54 written as lengths comes out 50/50 at every desktop
   width under 1240px — which is to say, written that way, `split` and
   media_right would be the same layout at exactly the widths most visitors are
   on, and the difference would only appear on the widest screens. Both tracks
   resolve against the grid's inline size, so the ratio holds the whole way up.

   Below 64em the picture is a full-width block INSIDE the container rather than
   a bleeding one, so it also loses media_right's negative bottom margin: that
   margin exists to land a bleeding image exactly on the section's bottom seam,
   and pulling a contained, rounded picture down over the seam would hang it off
   the band it belongs to.
   -------------------------------------------------------------------------- */
.vz-hero--split {
  --vz-hero-copy-w:  calc(var(--vz-hero-content) * 0.5);
  --vz-hero-media-w: calc(var(--vz-hero-content) * 0.5);
}

.vz-hero--split .vz-hero__media {
  grid-column: content-start / content-end;
  border-radius: var(--vz-radius);
  margin-block-end: 0;
}

@media (min-width: 64em) {
  /* mid / content-end, not mid / full-end. One line of difference from §4, and
     it is the one a visitor can see. */
  .vz-hero--split .vz-hero__media { grid-column: mid / content-end; }
}

/* --------------------------------------------------------------------------
   4b. layout="centered_bg" — the only place in the library where type sits on
       an arbitrary photograph

   WHICH MEANS CONTRAST CANNOT BE ASSERTED ONCE. Every other composition puts
   text on a token: the value is known, the ratio is computed, and it is either
   above the floor or it is not. Here the background is whatever an editor
   uploaded, and the floor has to hold against a picture nobody has seen. The
   scrim is what makes that possible, and its strength is measured rather than
   chosen — against the LIGHTEST region of the shipped default image, not its
   average, because the average is exactly the number that reads fine and ships
   a headline nobody can read over the one bright patch of wall behind it.

   THE MEASUREMENT, AND WHAT IT COST TO GET RIGHT. The shipped default (a mural
   in progress, Unsplash photo-1695237650123) was sampled at 240x300 in 12px
   tiles: mean rgb(133,130,133), relative luminance 0.226 — and the lightest
   tile rgb(237,239,225), luminance 0.849, a sunlit patch of wall.

   That measurement of the SOURCE FILE said 58% would do it: it predicted the
   composited tile at luminance 0.166 and white type on it at 4.85:1. The
   RENDERED page disagreed. hero-light-check.mjs screenshots the real page,
   hides the copy, and reads the pixels that were behind the subhead: at 58% the
   worst 4x4 tile came back at L=0.186 and the subhead at 4.44:1 — under the
   floor at all four widths, by six hundredths. The source file is not the
   backdrop: object-fit: cover crops it, the browser resamples it, and the
   JPEG's own chroma reconstruction is not the one node's downsample produced.
   62% is what the rendered page actually needs, and it is the reason the two
   paragraphs above are a record of a prediction rather than the authority for
   this number. The floors are 4.5:1 for the subhead and 3:1 for the h1; the
   subhead is the binding one, because it takes the same scrim at a smaller
   size.

   THE GRADIENT IS NOT DECORATION EITHER. It runs 78% at the top edge, eases to
   the 62% floor across the middle third where the copy sits, and closes at 76%
   at the bottom. The two ends are darker for two specific reasons rather than
   for shape: the top is what the frosted bar is glass over — the pill floats on
   the photograph in this branch and the wordmark has to survive it — and the
   bottom is the seam with whatever section follows, which is a hard edge
   between a photograph and a flat colour unless the picture is on its way down
   already. Nowhere on the gradient is weaker than the measured floor, so the
   easing costs no contrast anywhere the copy can reach.

   OVERLAY="NONE" IS AN ESCAPE HATCH AND IT IS DOCUMENTED AS ONE. Its field help
   says so: it is for artwork that is already dark across the whole frame, and
   the editor owns the check. The module does not silently keep a scrim the
   editor turned off, and it does not pretend the setting is safe.
   -------------------------------------------------------------------------- */
/* TWO THINGS HERE ARE DELIBERATE ABSENCES, and both were arrived at by getting
   them wrong first.

   The section gets `position: relative` ON THE ATTRIBUTE, not on the layout
   class and not unconditionally. `overflow: clip` does not create a containing
   block — §1's comment says it does, and it is wrong about that — so without
   this the picture would size itself against the initial containing block, i.e.
   the whole document, and the crop would be of a 6000px-tall box. Scoping it to
   [data-vz-surface-dark] means it lands on exactly the heroes whose ::before /
   ::after have been removed (§1a), so it cannot re-anchor the two glow layers
   and change the light on any composition that still has one.

   And .vz-hero__bg carries NO grid-column and NO grid-row. An absolutely
   positioned child of a grid container is positioned against its GRID AREA when
   one is specified — and the grid area is inside the section's block padding,
   which would leave a band of the section's own colour above and below a
   photograph that is supposed to be the surface. With no placement it falls
   back to the padding box, which is the full bleed this composition needs. */
.vz-hero[data-vz-surface-dark] { position: relative; }

.vz-hero__bg {
  position: absolute;
  inset: 0;
  z-index: 0;
  overflow: clip;
}

/* Doubled selector for the same reason footer.module doubles its image ones:
   base.css §1's `.vz-section img { height: auto }` is (0,1,1) and beats a
   block-size set on a single class — the defect CR-16 found on the quote's
   avatar, which rendered a 240x160 source as a 48x32 ellipse. */
.vz-hero .vz-hero__bg img {
  display: block;
  inline-size: 100%;
  block-size: 100%;
  object-fit: cover;
  object-position: center;
}

.vz-hero__bg--scrim::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  background-image: linear-gradient(
    to bottom,
    color-mix(in oklab, var(--vz-color-ink-deep) 78%, transparent) 0%,
    color-mix(in oklab, var(--vz-color-ink-deep) 62%, transparent) 34%,
    color-mix(in oklab, var(--vz-color-ink-deep) 62%, transparent) 68%,
    color-mix(in oklab, var(--vz-color-ink-deep) 76%, transparent) 100%
  );
}

/* One column at every width, centred — the composition has no second column to
   introduce, so there is no breakpoint here at all. The measure cap is a little
   wider than the copy-only case's 42rem because centred type needs the extra
   line length to stop reading as a stack of fragments, and still well inside
   the 65–75ch ceiling: 46rem at the subhead's size is about 62 characters. */
.vz-hero.vz-hero--centered_bg .vz-hero__copy {
  max-inline-size: 46rem;
  justify-self: center;
  text-align: center;
}

.vz-hero--centered_bg .vz-hero__copy .vz-kicker,
.vz-hero--centered_bg .vz-hero__actions { justify-content: center; }

.vz-hero--centered_bg .vz-hero__subtext { margin-inline: auto; }

/* A fold needs to be a fold. Without a floor the section is as tall as its copy,
   and a full-bleed photograph 260px high under three lines of text reads as a
   banner rather than as an opening. svh rather than vh so a phone's collapsing
   address bar does not make the picture jump; capped in rem so a short laptop
   screen still shows what is under the hero. */
@media (min-width: 48em) {
  .vz-hero--centered_bg[data-vz-surface-dark] { min-block-size: min(36rem, 82svh); }
}

/* THE TYPE GOES WHITE, AND IT GOES WHITE ON THE ATTRIBUTE RATHER THAN ON THE
   LAYOUT. Same condition as the suppressed glows in §1a: a photograph is here.
   A centered_bg hero with no picture is an ordinary coloured band and keeps
   whatever its Background field says.

   base.css flips .vz-kicker and .vz-btn--ghost to lime for its two dark section
   classes; this surface has no such class, so the same two flips are made here,
   from the same token, rather than a fourth colour being introduced.

   Pure white for the subhead, not --vz-color-on-dark. That token is white at
   82%, which is correct on a flat dark fill and costs about a point of contrast
   over a photograph — measured, on the shipped image's lightest region, it
   takes the subhead from 4.85:1 to 3.57:1 at the same scrim, i.e. under the
   floor. The hierarchy between headline and subhead is carried by size and
   weight in this branch instead, which is what the composition can afford and
   the contrast cannot. */
.vz-hero[data-vz-surface-dark] { color: var(--vz-color-white); }
.vz-hero[data-vz-surface-dark] .vz-hero__heading,
.vz-hero[data-vz-surface-dark] .vz-hero__subtext { color: var(--vz-color-white); }
.vz-hero[data-vz-surface-dark] .vz-kicker,
.vz-hero[data-vz-surface-dark] .vz-btn--ghost { color: var(--vz-color-accent); }

/* --------------------------------------------------------------------------
   4c. layout="embedded_form" — the form takes the picture's column

   THE COLUMN IS 27rem, WHICH IS form.module's OWN MEASURED VALUE (its §2), not
   a number picked to look like it: a form does not improve past about 27rem —
   the fields get longer, the labels stay where they are, and the eye has
   further to travel between them. Two modules on one page carrying two
   different form widths would be the library disagreeing with itself in the one
   place a visitor compares them directly.

   62em rather than §4's 64em, also form.module's: the copy beside a fixed 27rem
   column has more room at any given width than the copy beside a 54% media
   track, so it can stack later.

   THE TRACKS ARE RESHAPED, NOT REPLACED. §1's template is four named lines and
   two custom properties; setting the two properties moves the `mid` line and
   leaves every other guarantee in §1 intact — the outer minmax() floors, the
   agreement with .vz-container, the alignDelta that the whole grid exists for.
   A second grid-template-columns here would have to re-derive all of it.

   :has(.vz-hero__form) gates the two-up, because an unselected form is not a
   missing column — it is one column. Without it the copy would sit in a narrow
   track with a 27rem hole beside it, which is the state this module is most
   likely to be seen in: the moment after an editor drags it onto a page.

   The separation is padding on the copy column rather than column-gap, for §4's
   measured reason: a gap on a four-track grid comes out of the 1fr outer tracks
   first and pulls the content tracks off .vz-container's alignment.
   -------------------------------------------------------------------------- */
/* Stacked, the form takes the copy column's own cap rather than the full
   content width. Without it the two disagree by 156px at 900px wide — the copy
   is capped at 42rem by §2's no-media rule and the form was not capped at all,
   which reads as the form having escaped the column the copy is in. Measured;
   it is not visible from the stylesheet, because the two caps are set three
   sections apart. */
.vz-hero__form {
  grid-column: content-start / content-end;
  max-inline-size: 42rem;
}

@media (min-width: 62em) {
  .vz-hero--embedded_form:has(.vz-hero__form) {
    --vz-hero-media-w: 27rem;
    --vz-hero-copy-w:  calc(var(--vz-hero-content) - 27rem);
    align-items: center;
    row-gap: 0;
  }
  .vz-hero--embedded_form:has(.vz-hero__form) .vz-hero__copy {
    grid-column: content-start / mid;
    padding-inline-end: clamp(var(--vz-space-l), 5vw, var(--vz-space-3xl));
  }
  .vz-hero--embedded_form:has(.vz-hero__form) .vz-hero__form {
    grid-column: mid / content-end;
  }
}

/* THE FORM IS IN A PANEL, AND THE PANEL IS THE SURFACE THE FIELDS ARE ON.
   .vz-card-surface is applied in module.html (base.css §6a) — the fill, the
   inset, the 12px radius and the panel's own `color` all come from the
   primitive, so nothing about the box is re-derived here.

   WHAT THIS SECTION USED TO HOLD, AND WHY IT IS GONE. base.css §6b reads eight
   role variables; five of them were flipped to a dark set on this module's
   `.vz-section--bg-brand` / `--bg-ink` (and on [data-vz-surface-dark]), because
   the fields sat directly on that surface and a blue submit pill on navy is
   3.94:1. The panel removes the condition entirely rather than changing the
   answer: the fields are on white on all five backgrounds now, which is exactly
   what §6b's :root defaults are for. A flip kept "for the dark case" would have
   no dark case left to serve — it would inherit straight through the card and
   paint on-dark labels onto a white panel, which is the white-on-white failure
   base.css §6a's own `color` rule exists to prevent, one variable deeper.

   Deleted rather than narrowed with a :not(), because the panel is not optional
   in this branch: reaching this CSS at all means .vz-hero__form exists, and
   every .vz-hero__form this module emits carries .vz-card-surface.

   THE ELEVATION IS THE RAISED ONE (CR-21's token), NOT THE PRIMITIVE'S. --vz-shadow
   is a hairline — 8% of the ink at 8px blur — which reads as a panel on the
   cream wash and as very nearly nothing on `white`, and `white` is one of the
   five backgrounds an editor can pick. A card that disappears on one surface is
   not a card. This is the same call CR-21 made for card-grid's filled panel and
   the reason --vz-shadow-raised was added, so the hero reaches for it rather
   than inventing a third elevation.

   Still elevation-exclusive per §4.7: a fill and a shadow, no border.

   THE INSET IS RE-SIZED FOR A COLUMN, WHICH IS card-grid.module §4b's FIX FOR
   card-grid's OWN CELLS, APPLIED TO THE SAME DEFECT HERE. base.css §6a's
   padding is clamp(--vz-space-m, 4vw, --vz-space-2xl) — and the middle term is
   a fraction of the VIEWPORT, while this card is a fixed 27rem column that does
   not change width above 62em. At 1440 that resolves to 57.6px a side on a
   432px card: measured, 27% of the panel is inset and the fields are left with
   317px of the 432. The primitive's value is right for quote-block's figure
   spanning the container, which is what it was written for, and wrong for every
   fixed column that consumes it.

   Same expression as card-grid's, deliberately, so the library has ONE answer
   to "a panel narrower than the container" rather than three: half the slope,
   ceiling dropped one step. 24px at 360 to 32px at 1440 — and the fields go
   from 317px to 368px inside the same 432px column.

   The extra class is specificity, not decoration: .vz-card-surface sets both
   padding and box-shadow at (0,1,0) and this has to win whichever order the
   portal emits the two stylesheets in — the same doubling card-grid.module §4b
   uses on the same primitive for the same reason. */
.vz-hero .vz-hero__form.vz-card-surface {
  padding: clamp(var(--vz-space-m), 2.4vw, var(--vz-space-l));
  box-shadow: var(--vz-shadow-raised);
}

/* --------------------------------------------------------------------------
   4d. layout="trust_logos" — borrowed marks, under the actions

   WRAPPED FLEX, NOT A GRID, and the difference is what happens to six logos of
   six different aspect ratios. A grid gives each one an equal cell, so a wide
   wordmark and a square badge are padded to the same box and the row reads as a
   table of logos. Flex lets each keep its own width and the row keeps its own
   rhythm — which is also why the cap is on HEIGHT and the width is left to the
   artwork.

   GREYSCALE IS THE HOUSE TREATMENT FOR MARKS THAT ARE NOT OURS. Six brands at
   full saturation under a headline is six brands competing with it; drawn in
   grey they read as one band of evidence, and they come back to themselves
   under the pointer, which is where someone is actually looking at one of them.
   The lift is 2px — enough to answer, not enough to be a feature.

   The row is .vz-enter--4, the same rung as the actions it sits under, rather
   than a fifth rung of its own: it is part of that beat, and the load ladder is
   four rungs by design (base.css §7).
   -------------------------------------------------------------------------- */
.vz-hero__logos {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--vz-space-m) var(--vz-space-l);
  margin-block: var(--vz-space-xl) 0;
  padding: 0;
  list-style: none;
}

/* Rows added but no images picked yet. The HubL guard catches the no-rows case
   and cannot catch this one — an image field with an empty src is still an
   object — so the list is emitted and then removed here rather than leaving a
   gap under the buttons that nothing in the editor explains. */
.vz-hero__logos:not(:has(.vz-hero__logo)) { display: none; }

.vz-hero__logo { display: flex; }

/* Doubled ancestor, again for base.css §1's `.vz-section img { height: auto }`
   at (0,1,1) — the CR-16 lesson, applied before it can bite. Both axes auto
   with a cap on the block axis is what preserves each mark's own ratio while
   holding the row to one optical height. */
.vz-hero .vz-hero__logo img {
  display: block;
  inline-size: auto;
  block-size: auto;
  max-block-size: 2.125rem;
  /* THE HEIGHT CAP IS THE ONE THAT DOES THE WORK; the width one is a runaway
     guard and nothing else. At 9rem it was binding on two of the four test
     marks and the row came back at four different heights — 31/34/34/22px — so
     the thing that was supposed to make six mismatched logos read as one band
     was itself the reason they did not. 14rem clears a 6.5:1 wordmark at the
     capped height and still stops a 2000x40 banner from taking the row. */
  max-inline-size: 14rem;
  filter: grayscale(1);
  opacity: 0.66;
  transition: filter var(--vz-dur-slow) var(--vz-ease),
              opacity var(--vz-dur-slow) var(--vz-ease),
              translate var(--vz-dur) var(--vz-ease);
}

.vz-hero .vz-hero__logo img:hover {
  filter: grayscale(0);
  opacity: 1;
  translate: 0 -2px;
}

/* On the two dark surfaces and over a photograph the marks are lifted rather
   than dimmed: 66% of a dark logo on near-black is a smudge, and most partner
   artwork supplied for a dark band is already light. */
.vz-hero:is(.vz-section--bg-brand, .vz-section--bg-ink) .vz-hero__logo img,
.vz-hero[data-vz-surface-dark] .vz-hero__logo img { opacity: 0.82; }

/* --------------------------------------------------------------------------
   5. Motion

   THE HERO IS THE ONE MODULE THAT KEEPS THE LOAD LADDER. base.css §7 now has
   two: .vz-enter fires on the clock, .vz-reveal on the element's position in
   the viewport. Everything below the fold moved to the second one; this module
   is the fold, so its copy stays on .vz-enter (§7a) and the image keeps the one
   effect that is this module's own — a wipe from the leading edge, which is
   what makes the bleed read as the image arriving rather than as a block fading
   in. Put on a scroll timeline the hero would be at 100% progress before a
   visitor saw it and the entrance would simply never happen.

   Like every animation in this system it runs FROM hidden with `backwards`
   fill, so the image is visible by default. If the animation never fires —
   reduced motion, print, a browser without the feature — nothing ships blank.
   -------------------------------------------------------------------------- */
@keyframes vz-hero-reveal {
  from {
    opacity: 0;
    clip-path: inset(0 0 0 18%);
    transform: scale(1.03);
  }
}
.vz-hero .vz-hero__media img {
  animation: vz-hero-reveal 900ms var(--vz-ease-expo) 120ms backwards;
}

/* The drift. The composition's whole idea is that the image is not in the
   column — it runs past the container and off the edge of the screen — and a
   photograph that travels at a different rate to the copy beside it is that
   idea in motion rather than in layout. ±2% of its own height, which is a few
   pixels a second at reading speed: felt, not watched.

   It costs a permanent 1.05 crop, which is what gives the travel somewhere to
   go — object-fit: cover means a tighter crop and never a distortion, and
   .vz-hero__media's `overflow: clip` (see §3, where it is already load-bearing)
   contains all of it. 2.5% of overflow either side against 2% of travel leaves
   the frame covered at both ends of the range, so the bleed never opens a gap
   and the mobile composition's flush bottom seam is untouched.

   `translate` and `scale` are the independent transform properties, not
   `transform`, so they compose with the wipe's own scale instead of replacing
   it. The wipe stays on the clock (`auto`) while the drift runs on the scroll
   timeline — one element, two timebases, which is the pair the effect needs.

   Redeclared rather than added to, because CSS has no way to append to an
   animation list. Outside this block the declaration above stands unchanged,
   and reduced motion falls through to the branch below.
   -------------------------------------------------------------------------- */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    @keyframes vz-hero-drift {
      from { translate: 0 -2%; }
      to   { translate: 0 2%; }
    }
    .vz-hero .vz-hero__media img {
      scale: 1.05;
      animation: vz-hero-reveal 900ms var(--vz-ease-expo) 120ms backwards,
                 vz-hero-drift linear both;
      animation-timeline: auto, view();
      animation-range: normal, cover 0% cover 100%;
    }
  }
}

/* --------------------------------------------------------------------------
   6. The fold arriving, and two small things that reward looking

   THE LIGHT COMES UP WITH THE PAGE. One fade, 1.4s, on the atmosphere layer
   only — slower than every entrance in the load ladder and starting under them,
   so the copy rises into a surface that is still brightening rather than onto a
   finished one. It is the page's single orchestrated load moment; nothing else
   in this module was given one.

   Like everything else here it animates FROM transparent with `backwards`, so
   a renderer that runs no animation paints the lit surface directly.

   THE KICKER'S RULE DRAWS ITSELF. base.css gives .vz-kicker a 28px coral bar;
   here it arrives by being drawn from its leading edge, on the shared vz-wipe-x
   keyframe the card grid and the quote block already use for their hairlines.
   No new vocabulary, one more thing that behaves like it was made rather than
   placed — and it lands just after the eyebrow's own entrance, so the line
   writes itself and then the rule underlines it.

   THE PRIMARY ACTION CATCHES THE LIGHT. The button sits in a lit field, so
   under the pointer it blooms rather than merely darkens. Hover and
   focus-visible both, because a keyboard visitor is looking at the same
   control; the outline ring is untouched and still does the accessibility work.
   This is the one place the module touches a base.css primitive's state, and it
   is scoped to .vz-hero__actions so no other button on the site inherits a
   glow it was not designed for.
   -------------------------------------------------------------------------- */
@keyframes vz-atm-in {
  from { opacity: 0; }
}
.vz-hero::before,
.vz-hero::after {
  animation: vz-atm-in 1400ms var(--vz-ease) 60ms backwards;
}

/* THE LIGHT BREATHES. Two loops, deliberately mismatched — 34s and 43s, with
   the second reversed — so the pair never returns to the same arrangement
   twice inside a visit. A single shared duration is what makes ambient motion
   read as a loop rather than as weather.

   It is slow enough not to be watchable: the coral travels about 3% of the
   section in ten seconds, which is under a pixel a second. You notice it by
   looking away and back, which is the point — the fold is not static, and it
   never asks to be looked at.

   `translate` and `scale`, so both layers stay on the compositor: no repaint,
   no layout, nothing that costs a phone anything. And both are already inside
   §1's `overflow: clip`, so a layer 6% oversized never widens the page.

   Redeclared with the entrance rather than added to it, because CSS has no way
   to append to an animation list — the same note §5 carries for the image. */
@media (prefers-reduced-motion: no-preference) {
  @keyframes vz-atm-drift-a {
    50% { translate: 2.5% -1.5%; scale: 1.06; }
  }
  @keyframes vz-atm-drift-b {
    50% { translate: -3% 2%; scale: 1.05; }
  }
  /* `will-change` because these two are the only permanently-animating boxes on
     the page and they are the size of the fold. Without it each step of the
     drift repaints two full-section gradient stacks on the main thread — 38ms a
     frame against 21ms with, measured on the assembled page. It is the case the
     property exists for: a known, continuous, compositor-only animation on a
     small, fixed number of elements. Inside the no-preference block, so a
     visitor who asked for less motion is not charged two layers of GPU memory
     for an animation that never runs. */
  .vz-hero::before {
    will-change: transform;
    animation: vz-atm-in 1400ms var(--vz-ease) 60ms backwards,
               vz-atm-drift-a 34s var(--vz-ease) infinite alternate;
  }
  .vz-hero::after {
    will-change: transform;
    animation: vz-atm-in 1400ms var(--vz-ease) 60ms backwards,
               vz-atm-drift-b 43s var(--vz-ease) infinite alternate reverse;
  }
}

/* THE HEADLINE ARRIVES A WORD AT A TIME. The whole line rising as one block is
   the entrance every other element on this page makes; the h1 is the one line a
   visitor actually reads, and letting it assemble is the difference between a
   page that loads and a page that opens.

   Each word carries its own --vz-i from the HubL loop (module.html), which is
   the same indexed-stagger pattern base.css §7c uses for repeater rows — one
   vocabulary, not two. 70ms apart: at the four-word headline this module is
   built around that is a quarter of a second end to end, and even a pathological
   twelve-word one finishes inside a second.

   The blur is what makes it read as focus pulling rather than as text sliding.
   It is 0.12em rather than a pixel value so it scales with the type — the same
   effect at 47px on a phone as at 90px on a desktop — and it is on a handful of
   short-lived spans, which is the only place a text blur is cheap.

   inline-block is required (a transform does nothing to an inline box) and is
   also why module.html emits a real space between the words. */
@keyframes vz-hero-word {
  from {
    opacity: 0;
    transform: translateY(0.32em);
    filter: blur(0.12em);
  }
}
.vz-hero .vz-hero__word {
  display: inline-block;
  animation: vz-hero-word 760ms var(--vz-ease-expo)
             calc(140ms + var(--vz-i, 0) * 70ms) backwards;
}

.vz-hero .vz-hero__eyebrow::before {
  transform-origin: left center;
  animation: vz-wipe-x var(--vz-dur-slow) var(--vz-ease-expo) 240ms backwards;
}

/* THE PICTURE ANSWERS THE CURSOR, AND IT ANSWERS AS A LENS. What used to be
   here was a light held up to the photograph — a specular bloom and a sheen,
   both of them things laid ON the image. This is the picture's own optics
   instead: it disperses. Chromatic aberration, done in the brand's two poles
   rather than in the red/cyan every other site does it in, which is the whole
   reason it reads as VizSense and not as a filter.

   IT IS A CHANNEL SPLIT, NOT A TINT, and the difference is what keeps it from
   looking like two coloured ghosts. Each pseudo-element paints the same
   photograph MULTIPLIED by one brand colour, and the pair is composited back
   over the untouched image with `lighten`:

     ::before   image x --vz-color-primary     #3e6aff — blue 1.00, red 0.24.
                A copy that carries the cool end and almost nothing else.
     ::after    image x --vz-color-accent-warm #ea8161 — red 0.92, blue 0.38.
                A copy that carries the warm end.

   Each tint passes its own dominant channel nearly untouched and suppresses the
   others, which is a channel isolate in everything but name — and the two are
   near enough complementary that together they add back up to the picture.

   `lighten` is what makes it cost nothing where nothing is happening. A
   multiplied copy is DARKER than its source everywhere the two are aligned, so
   across flat areas the layers lose to the photograph pixel for pixel and
   contribute exactly zero: no wash, no haze, no grey. They surface only where
   the displacement has carried a bright pixel over a dark one — which is an
   edge, which is where a lens fringes. The effect writes itself onto the
   picture's own contrast instead of being painted over the top of it.

   THE DIRECTION OF THE SPLIT IS PHYSICS, NOT PREFERENCE. Blue refracts hardest
   and focuses short, so it lands magnified LESS; the warm end lands magnified
   more. Hence 1 - k on the blue copy and 1 + k on the coral one — a differential
   magnification, which puts the separation at zero in the middle of the frame
   and at its maximum in the corners. That gradient is the whole tell: a uniform
   offset reads as a printing misregistration, a radial one reads as glass.

   AND THE AMOUNT ANSWERS THE POINTER, for the same reason. A lens is sharp on
   axis and disperses off it, so --vz-media-d (module.js: how far off the
   photograph's own centre the pointer is, 0 at the middle, 1 at the frame and
   beyond) drives k. Move toward a corner and the fringes bloom; cross the middle
   of the picture and it very nearly comes back to itself. The pointer's
   direction rides on `transform` — a few pixels of lateral pull, which is the
   lens being tilted rather than merely used.

   THE WHOLE FOLD IS THE TRIGGER, AND STILLNESS PUTS IT BACK. module.js listens
   on the SECTION, not on the picture: the fold is one composition and the lens
   answers a pointer anywhere in it, including out over the headline where the
   photograph is furthest off axis. What replaces the hover boundary as the
   off switch is movement itself. --vz-media-charge is a value module.js drives
   to 1 on any pointer movement and lets decay on a half-life once the movement
   stops, so the effect is alive while a visitor is moving through the fold and
   settles back to the untouched photograph within about a second and a half of
   them stopping — or of them leaving, which is the same thing to a decay and is
   why there is no pointerleave branch anywhere in this module.

   The two multiply, and each is one thing: charge is WHETHER, distance is HOW
   MUCH. A floor under the distance term keeps a moving pointer from producing
   nothing at all as it crosses the optical centre.

   `transform` and not `translate` because `translate` is already spoken for:
   §5 hangs the scroll drift on it. The two are independent properties and
   compose, so the copies keep travelling with the photograph they are copies of
   — which they have to. A fringe layer that stays put while the image drifts
   2% of its height is not an aberration, it is a double exposure. Same reason
   --vz-ca-base picks up §5's permanent 1.05 crop under the same @supports
   condition that applies it: the copies are only ever the image, moved.

   THE LAYERS SIT ON .vz-hero__frame, NOT ON .vz-hero__media, and §3 has the
   whole reason: a blend needs an opaque backdrop, and the media box's rounded
   corners do not have one. Composited on the square frame and clipped by the
   box outside it, the corners come out as the picture's own edge.

   `mix-blend-mode` is contained by §1's `isolation: isolate`, and again by the
   frame's own, so neither layer can reach the page behind the hero. Both are
   pointer-events: none and both are gated on module.js having published
   --vz-media-img — no script, no layers, and the photograph is exactly what it
   always was.

   The whole effect is behind prefers-reduced-motion: no-preference. Two copies
   of a 700px picture sliding against each other is large-area movement, and the
   setting means what it says. */
@media (prefers-reduced-motion: no-preference) {
  .vz-hero__media {
    /* Dispersion, 0..1: how much the lens is being used, times how far off its
       axis. Both come from module.js and both default to 0, so an unarmed page
       computes this to nothing rather than to a floor. The 0.18 is a floor under
       the DISTANCE term only — a pointer crossing the optical centre still
       fringes a little — and it is deliberately not a floor under the whole
       expression, because "returns to the original image" has to mean zero. */
    --vz-ca: calc(var(--vz-media-charge, 0) * (0.18 + 0.82 * var(--vz-media-d, 0)));
    /* The radial term: one percent of the frame between the two copies at the
       edges, which is ~7px of separation across a 700px picture and ~3px across
       a phone's. It scales with the image because it is a magnification, not an
       offset. Tuned against the shipped photograph at 1:1 rather than zoomed:
       0.7% was present but only findable if you knew to look for it, and 1.4%
       stopped reading as a lens and started reading as a misprint. */
    --vz-ca-spread: calc(var(--vz-ca) * 0.010);
    /* The lateral term. A length, so it does NOT scale with the image — the
       tilt is the same four pixels whatever size the photograph is printed
       at. */
    --vz-ca-shift: calc(var(--vz-ca) * 0.26rem);
    --vz-ca-base: 1;
  }

  .vz-hero__frame::before,
  .vz-hero__frame::after {
    content: '';
    position: absolute;
    inset: 0;
    z-index: 1;
    pointer-events: none;
    opacity: 0;
    /* cover + center is object-fit: cover + object-position: center, which is
       what the img inside is doing (§3). The two boxes are the same box, so the
       copies register with the photograph exactly. */
    background-image: linear-gradient(var(--vz-ca-tint), var(--vz-ca-tint)), var(--vz-media-img);
    background-blend-mode: multiply;
    background-size: cover;
    background-position: center;
    background-repeat: no-repeat;
    scale: var(--vz-ca-base);
    transform: none;
    transition: opacity 380ms var(--vz-ease),
                scale 260ms var(--vz-ease),
                transform 260ms var(--vz-ease),
                mix-blend-mode 0s linear 380ms;
    /* Its own declaration rather than the `allow-discrete` keyword inside the
       shorthand above: an unknown keyword would invalidate the WHOLE shorthand
       and take the opacity fade with it, whereas an unknown property is simply
       dropped. Where it lands, the blend mode holds until the fade has finished
       and the layers go out blended. Where it does not, they flip to normal
       composition for the length of the fade — the same artefact the specular
       this replaced already shipped with, and no worse. */
    transition-behavior: allow-discrete;
  }

  /* Without a source there is nothing to disperse, and an unset --vz-media-img
     makes background-image invalid at computed-value time — it resolves to
     `none` and the layers paint nothing at all. That is the no-JS path, and it
     is silent by construction rather than by a guard. */
  .vz-hero__frame::before { --vz-ca-tint: var(--vz-color-primary); }
  .vz-hero__frame::after  { --vz-ca-tint: var(--vz-color-accent-warm); }

  /* §5's scroll drift, on the copies. Declared under the same condition that
     gives the img its permanent 1.05 crop, so the three stay one image.

     THE RANGE HAS TO STAY IN STEP WITH §5's. These are three elements running
     what has to be one motion; a range changed on the img alone would leave the
     copies at a different point on the timeline and turn the aberration into a
     double exposure. If §5's `animation-range` moves, this one moves with it. */
  @supports (animation-timeline: view()) {
    .vz-hero__frame::before,
    .vz-hero__frame::after {
      --vz-ca-base: 1.05;
      animation: vz-hero-drift linear both;
      animation-timeline: view();
      animation-range: cover 0% cover 100%;
    }
  }

  /* THE BLEND MODES ARE DECLARED ON THE LIT STATE, NOT ON THE LAYERS, and that
     is a performance fix rather than tidiness. A blended box costs its stacking
     context a main-thread composite on every frame WHETHER OR NOT it is visible
     — opacity 0 does not exempt it. Measured on the assembled page in headless
     Chrome: 38ms a frame with these two declared at rest against 16.7ms
     without, on a page where nothing was moving. That is the resting cost of
     the whole document, paid by every visitor, for an effect only a mouse can
     ever see. Declared here they exist for exactly as long as a cursor is over
     the photograph.

     The dispersion lives here too, so the resting state is the two copies at
     dead register — where `lighten` already renders them invisible — and the
     exit is the fringes converging back into the picture rather than a light
     switching off. */
  .vz-hero__media[data-vz-lit] .vz-hero__frame::before,
  .vz-hero__media[data-vz-lit] .vz-hero__frame::after {
    opacity: 1;
    mix-blend-mode: lighten;
    transition: opacity 380ms var(--vz-ease),
                scale 260ms var(--vz-ease),
                transform 260ms var(--vz-ease);
  }

  .vz-hero__media[data-vz-lit] .vz-hero__frame::before {
    scale: calc(var(--vz-ca-base) - var(--vz-ca-spread));
    transform: translate(calc(var(--vz-media-nx, 0) * var(--vz-ca-shift) * -1),
                         calc(var(--vz-media-ny, 0) * var(--vz-ca-shift) * -1));
  }
  .vz-hero__media[data-vz-lit] .vz-hero__frame::after {
    scale: calc(var(--vz-ca-base) + var(--vz-ca-spread));
    transform: translate(calc(var(--vz-media-nx, 0) * var(--vz-ca-shift)),
                         calc(var(--vz-media-ny, 0) * var(--vz-ca-shift)));
  }

  /* The glass is wide open, so the picture gets a touch more contrast for the
     fringes to hold onto and a touch more colour with them. Smaller than the
     lift the old pointer light carried — the aberration is now doing the work
     that saturation used to have to do on its own. */
  .vz-hero .vz-hero__media img {
    transition: filter 500ms var(--vz-ease);
  }
  .vz-hero__media[data-vz-lit] img {
    filter: saturate(1.06) contrast(1.06);
  }
}

/* Blue on the light surfaces, lime on the dark ones — the halo is the button's
   own colour, which is the only way it reads as light on the control rather
   than as a shadow under it. */
.vz-hero { --vz-hero-halo: var(--vz-color-primary); }
.vz-hero:is(.vz-section--bg-brand, .vz-section--bg-ink) { --vz-hero-halo: var(--vz-color-accent); }

.vz-hero__actions .vz-btn:not(.vz-btn--ghost) {
  transition: background-color var(--vz-dur) var(--vz-ease),
              color var(--vz-dur) var(--vz-ease),
              transform var(--vz-dur) var(--vz-ease),
              box-shadow var(--vz-dur-slow) var(--vz-ease);
}
.vz-hero__actions .vz-btn:not(.vz-btn--ghost):hover,
.vz-hero__actions .vz-btn:not(.vz-btn--ghost):focus-visible {
  box-shadow: 0 0.55rem 1.6rem color-mix(in oklab, var(--vz-hero-halo) 34%, transparent);
}

@media (prefers-reduced-motion: reduce) {
  .vz-hero .vz-hero__media img,
  .vz-hero::before,
  .vz-hero::after,
  .vz-hero .vz-hero__word,
  .vz-hero .vz-hero__eyebrow::before { animation: none; }
  .vz-hero__actions .vz-btn:not(.vz-btn--ghost) { transition: none; }
  /* The logo row's lift and its return to colour both go; the marks stay grey
     and stay legible, which is the whole of the resting state anyway. The
     hover STATE is left alone — a visitor who asked for less motion still gets
     the colour back under the pointer, it just arrives without a transition. */
  .vz-hero .vz-hero__logo img { transition: none; }
  .vz-hero .vz-hero__logo img:hover { translate: none; }
  /* The headline is still four words in a heading — the spans are markup, not
     motion, and only the animation is dropped. */
}

/* The atmosphere is a background image on a pseudo-element, so it prints only
   if the visitor asked for backgrounds — and then it prints as three bands of
   ink-heavy colour behind the headline. The pointer light has no meaning on
   paper at all. */
@media print {
  .vz-hero::before,
  .vz-hero::after,
  .vz-hero__frame::before,
  .vz-hero__frame::after { display: none; }
  .vz-hero .vz-hero__word { animation: none; }
}
