/* ==========================================================================
   scroll.css — scroll-driven presentation
   --------------------------------------------------------------------------
   Loaded AFTER styles.css, layout.css and nav.css. See layout.css for why the
   CSS is split by domain rather than kept in styles.css.

   OWNS: resting + finished states for scroll-triggered content, pinned
         section scaffolding, sticky rails, parallax containers,
         scroll-linked SVG/line-art.
   DOES NOT OWN: the GSAP timelines themselves (gsap-motion.js), nav chrome
         (nav.css), hover/press springs (motion.css).

   Hard constraints:
     - Lenis is REJECTED. Native scroll + CSS `scroll-behavior: smooth` only.
       Four smooth-scroll approaches were measured and rejected; its
       virtual-scroll event is notify-only so momentum cannot be clamped.
       Do not reintroduce a scroll-hijacking library.
     - prefers-reduced-motion must land every element in its FINAL state.
       Author finished states here; from-states belong in JS at runtime only.
     - No horizontal overflow at 1440 / 820 / 390. Horizontal-scroll tracks
       must be opt-in containers, never the document.
   ========================================================================== */

/* ==========================================================================
   HOW THIS FILE STAYS REDUCED-MOTION SAFE
   --------------------------------------------------------------------------
   gsap-motion.js `return`s before it touches the DOM when
   prefers-reduced-motion is set, which means `html.gsap-on` is never added
   and none of the runtime scaffolding below is ever built.

   So the rule for this file is mechanical:

     * Anything that changes LAYOUT for a scroll effect is gated on
       `html.gsap-on`. Without that class the page lays out exactly as
       styles.css + layout.css describe it.
     * Every gated rule still describes a FINISHED, VISIBLE state. There is
       no `opacity: 0`, no `transform` that parks content off-screen and no
       zero-area box anywhere in this file. The from-states live only in
       gsap.set() calls at runtime, and every one of them is either scrubbed
       (so its resting value IS the finished value) or covered by the
       anti-stranding watchdog in gsap-motion.js.
     * NOTHING here is wider than its scrollport. There used to be one
       exception -- the pinned boarding-pass filmstrip, whose `.pass-wall`
       exceeded the viewport inside a clipping `.hviewport`. That treatment was
       removed (see section 3), so the exception is gone and the rule is now
       absolute.
   ========================================================================== */


/* ==========================================================================
   1. Depth planes — the pinned hero
   --------------------------------------------------------------------------
   The hero is pinned and scrubbed (gsap-motion.js §4a): five layers separate
   at different rates while the section holds still, so the first gesture on
   the page is the page reacting to you rather than a list of fades.

   The layers were chosen by ELIMINATION, not by taste. `transform` on this
   page is contested — `.reveal` carries `transform: translateY(22px)` plus a
   .6s CSS transition on transform, and motion.css/Motion owns transform on
   hover for the cards. So the parallax may only touch nodes that neither
   system writes:

     .hero-canvas-wrap   plain positioned box, no .reveal, no hover spring
     .home-inner         the band-inner wrapper
     .hero-name          the <h1>; its .hero-line CHILDREN are the .reveals
     .hero-lower         plain grid wrapper; its children are the .reveals
     .hero-fg            injected at runtime, owned by nobody else

   `.hero-meta` / `.hero-subtitle` / `.hero-intro` / `.explore-cue` are all
   `.reveal` and are therefore deliberately NOT in that list — they ride
   `.home-inner` instead. Animating them directly would put a per-frame
   inline transform in a race with a .6s CSS transition on the same
   property, which is the exact failure this repo has already paid for
   three times.
   ========================================================================== */

/* The hero is pinned at `top top`, so it wants to be exactly one viewport
   tall or a sliver of the next band shows under it for the whole pin.
   This is a FINISHED state (a taller hero), not an animation state. */
html.gsap-on .band-home {
    min-height: 100vh;
    /* The injected foreground plane travels past the section edges; clip so
       it can never widen the document. `clip` (not `hidden`) so no scroll
       container is created and the sticky topbar is unaffected. */
    overflow: clip;
}
@supports (min-height: 100svh) {
    html.gsap-on .band-home { min-height: 100svh; }
}

/* `will-change` on the four planes the pin actually moves — AND ONLY AT THE
   WIDTHS WHERE THE PIN RUNS.

   The query is a copy of gsap-motion.js §4a's `mmHero.add("(min-height: 560px)
   and (min-width: 900px)")`; keep the two in step. Below it the pin is
   deliberately not created, so an unconditional hint promoted four nodes to
   their own compositor layers — including `.hero-canvas-wrap`, a full-width
   <canvas> box, and `.hero-name`, a display-type <h1> whose text rasterises
   differently once it is on its own layer — for an animation that never
   arrives. `will-change` is a promise to the compositor, and a promise that is
   never kept is pure cost: it is held for the entire life of the document
   because nothing here ever takes it back.

   Measured at a 390x900 viewport: 4 permanently-promoted nodes before, 0
   after. At 1440 the count is unchanged, which is the point — the desktop
   hint is real and stays. */
/* `.hero-canvas-wrap` was DROPPED from this list: the hero pin no longer
   scrubs the canvas plane (gsap-motion.js §4a — the putting green must hold
   still while you aim at it), so promoting a full-width <canvas> to its own
   compositor layer for the whole life of the page now buys nothing and costs
   the layer. The four planes that still parallax keep the hint. */
@media (min-width: 900px) and (min-height: 560px) {
    html.gsap-on .home-inner,
    html.gsap-on .hero-name,
    html.gsap-on .hero-lower { will-change: transform; }
}

/* Line-art plane. Injected by gsap-motion.js, so it exists only when we are
   allowed to animate — there is no reduced-motion state of it to get wrong.

   Placed lower-LEFT on purpose. The right half of the hero is already the
   HeroCanvas contour field; a second set of sage curves over there simply
   merged into it, and two planes reading as one plane is the opposite of
   depth. So it travels through the left column at ~4x the copy's rate.

   z-index 0 — BEHIND the copy (`.home-inner > *` is z-index 1).

   It was originally z-index 2, deliberately in FRONT, on the reasoning that a
   depth stack wants something nearer than the subject. Reviewed and reversed:
   in front, the plane drew over the subtitle and the intro paragraph, and no
   amount of alpha makes ink on top of body copy read as atmosphere — it reads
   as damage. Behind the copy the parallax rate still separates it from every
   other plane, which is the effect that mattered; occlusion was never the part
   doing the work. Readability wins the tie.

   (The two full-height straight verticals that made this worst are gone — see
   the note in gsap-motion.js §4a.) */
/* ---- OFFSETS ARE NON-NEGATIVE ON PURPOSE — do not put them back ----
   These were `left: -9%; bottom: -14%`, which hung the plane off the band's
   lower-left corner. `html.gsap-on .band-home` sets `overflow: clip` (above), so
   everything outside the band was CUT — and both of this plane's curves have
   their terminals in exactly that corner (`M12 196` and `M46 214` in a
   0 0 300 220 viewBox), so the clip severed both curve ends rather than trimming
   empty SVG box.

   This is the same defect styles.css already fixed for `.shape-field`, and its
   note there states the rule this plane was violating: a soft radial wash may
   bleed off-screen invisibly (which is why `.glow` still does), but these are
   HARD STROKES, and a severed stroke reads as a rendering fault rather than as
   bleed. `.shape-field` was fixed at 7-25% clipped; this plane was worse.

   MEASURED, real stroke ink (not the SVG box) against the clip, at TRUE REST —
   inline transform confirmed identity, i.e. the first thing a visitor sees:

       viewport    cut off left   cut off bottom
        900x900        60.1px         115.6px
       1024x900        68.4px         114.1px
       1440x900       104.8px         113.6px
       1920x900       148.0px         113.6px
       2560x900       205.6px         113.6px
       3840x900       320.8px         113.6px

   After: 0px on every edge at every one of those widths, verified at rest AND at
   peak pin progress. The bottom cut used to vanish mid-pin (the plane travels
   `y: -0.26 * innerHeight`, so scrolling lifted the ink back into the band) —
   which is precisely why it had to be measured at rest. A probe that scrolls
   first reports cutBottom 0 and calls this clean.

   WHY 0 AND NOT A SMALLER NEGATIVE. The offsets resolve against the BAND, but
   the ink's inset from its own box edge is a fraction of the BOX, which is capped
   at 620px. So the two scale apart: any fixed negative percentage that just fits
   at 1440 cuts deeper at 2560 and deeper again at 3840. 0 is the only value that
   holds at every width.

   The plane moves right/up by the offsets it no longer has, so more of it sits
   behind the copy. That is already this plane's accepted design: it is z-index 0
   and `.home-inner > *` is z-index 1 (styles.css:679), so the copy always paints
   on top — see the z-index note above, where being BEHIND the copy was chosen
   deliberately and readability won the tie. Measured worst overlap with hero text
   after the change: ~2934px^2, against `.explore-cue`, at ~.18 effective alpha. */
.hero-fg {
    position: absolute;
    left: 0;
    bottom: 0;
    width: min(58%, 620px);
    height: auto;
    z-index: 0;
    pointer-events: none;
    overflow: visible;
    /* .36 x the strokes' own .5 => ~.18 effective alpha of --sage. */
    opacity: .36;
}
.hero-fg .hero-fg-line {
    fill: none;
    stroke: var(--sage);
    stroke-width: 1.1;
    stroke-linecap: round;
    opacity: .5;
}
.hero-fg .hero-fg-dot { fill: var(--olive); stroke: none; opacity: .5; }

/* Decoration is the first thing to go when space is tight — same rule the
   `.shape-field` layer already follows. */
/* Where the hero pin does not run, this plane has no parallax to justify it and
   would just be static ink near the copy. Hide it at exactly the breakpoint the
   pin stands down, so there is no orphaned decoration.

   THIS QUERY IS THE COMPLEMENT OF THE PIN'S OWN QUERY. gsap-motion.js §4a is
   `mmHero.add("(min-height: 560px) and (min-width: 900px)")`, so the pin runs
   only when BOTH hold, and this plane must be hidden when EITHER fails — hence a
   comma (OR), not a single max-width.

   IT USED TO BE `@media (max-width: 819px)` ALONE, which was wrong twice over,
   and the comment above it claimed it matched the pin. It did not:

     * WRONG WIDTH. The pin's gate is 900px, not 820px, and gsap-motion.js §4a
       spells out why in its own comment ("900px, NOT 820px: it must match
       HeroCanvas.tsx's own `narrow = cssW < 900` and layout.css's
       `max-width: 899px` band reservation"). That left 820-899px showing the
       plane with no pin behind it.
     * MISSING THE HEIGHT TERM ENTIRELY. The pin also opts out below 560px tall,
       so every short viewport — landscape phones, split-screen, a short desktop
       window — was an orphan at ANY width.

   Both cases were also where the clipping was worst, because a short band clips
   more of a plane whose height does not shrink with it. Measured before, real
   stroke ink cut off (left / bottom), all with the pin provably not running:

        820x900   54.8 / 118.7      <- width orphan
        880x900   58.8 / 120.9      <- width orphan
        844x390   56.4 / 110.2      <- landscape phone
        926x428   61.8 /  81.0      <- landscape phone
       1024x480   68.4 /  84.7      <- short desktop window
       1440x480  104.8 /  97.9      <- short desktop window

   Verified after: hidden at all six, still shown at 900x900 and above where the
   pin does run. `.98` on both terms so the boundary values 900 and 560 — the
   first widths/heights at which the pin DOES run — cannot be caught by both
   queries at once. */
@media (max-width: 899.98px), (max-height: 559.98px) {
    .hero-fg { display: none; }
}


/* ==========================================================================
   2. Scroll-linked line-art — contour density
   --------------------------------------------------------------------------
   The `.shape-field` strokes now DRAW THEMSELVES against scroll progress
   (gsap-motion.js §2) instead of playing a fixed 1.9s tween on enter, and
   their weight reacts to scroll velocity: scroll hard and the contours
   thicken, coast and they settle back to their resting hairline.

   Velocity is expressed as ONE inherited custom property on <html> rather
   than N inline stroke-widths. That keeps the whole effect to a single
   tweened value, leaves every stroke's `opacity` alone (styles.css still owns
   the .24/.18/.2 register and the `.band--dark` overrides), and means the
   resting value 1 reproduces the original weights exactly.

   Gated on html.gsap-on: with motion off, `--contour` is never defined and
   never referenced, so styles.css's plain stroke-widths apply untouched. */
html.gsap-on { --contour: 1; }

html.gsap-on .shape-field .shape-line,
html.gsap-on .shape-field .shape-ring { stroke-width: calc(1.3px * var(--contour)); }
html.gsap-on .shape-field .shape-tick { stroke-width: calc(1.1px * var(--contour)); }
html.gsap-on .hero-fg .hero-fg-line { stroke-width: calc(1.1px * var(--contour)); }

/* The parallax handle gsap-motion.js injects INSIDE each field's <svg>,
   wrapping the existing `.shape-drift` group. Two nested groups so the
   scroll-linked plane offset and the endless ambient drift are never the
   same property on the same node. Nothing to declare visually — it exists
   purely so the field's own CSS `transform` (`.shape-field--travel` is
   centred with translateY(-50%)) is never clobbered by a GSAP write. */


/* ==========================================================================
   3. REMOVED — horizontal boarding-pass track  (travel.html)
   --------------------------------------------------------------------------
   This section held the CSS for a pinned filmstrip: `.pass-wall` re-laid-out
   as a single row wider than the viewport inside a clipping `.hviewport`, with
   vertical scroll remapped to horizontal travel.

   Removed with gsap-motion.js §6b on review — the sideways motion was welded to
   the page scroll, and a filmstrip only ever showed one or two of eleven trips.
   layout.css's 12-column bento (which the track used to override above 1024px)
   now shows every pass at once, and nothing here is scroll-driven.

   Nothing injects `.hstage` / `.hviewport` / `.pass-depth` any more, so these
   rules matched nothing and are deleted rather than left as dead weight.

   The reduced-motion collapse that used to accompany them in section 4 is gone
   for the same reason — see the note at the top of that section. Keeping
   `display: none` / `display: contents` rules for markup nothing builds any
   more is not "cheap insurance": deleting section 3's rules while leaving
   section 4's is precisely what broke this file. That deletion took section
   4's OPENING COMMENT DELIMITER with it, leaving section 4's header as bare
   text; the parser then swallowed the header and the whole @media block that
   followed it as one invalid selector and dropped both. Measured before the
   repair: 0 prefers-reduced-motion rules in scroll.css against 4 in
   styles.css, 2 in layout.css, 1 in motion.css.

   NOTE the overflow contract this section used to carry: `.pass-wall` was the
   only box on the site permitted to exceed the viewport width. That permission
   is withdrawn — nothing should be wider than its scrollport now, and
   tools/a11y-motion/gate_dir.py asserts exactly that at 1440/820/390.
   ========================================================================== */


/* ==========================================================================
   4. Reduced motion — the safety net
   --------------------------------------------------------------------------
   Belt-and-braces only: gsap-motion.js returns before it adds `html.gsap-on`
   or builds any of the scaffolding above, so under this query the selectors
   in sections 1-3 match nothing at all. These rules exist for the one case
   the class list can still be wrong — a preference flipped mid-session,
   after the scaffolding was already built — and they all point the same way:
   FINISHED AND VISIBLE.

   Never add a `display: none`, an `opacity: 0` or an off-screen transform to
   this block for anything that carries CONTENT. Stranding content invisible is
   the #1 regression in this repo and this is the last line of defence against
   it. (`.hero-fg` at the bottom is the one exception, and it is only allowed
   because it is aria-hidden ornament that JS injects — see its own note.)

   EVERY SELECTOR HERE MUST MATCH SOMETHING THAT EXISTS. The rules for the
   deleted filmstrip (`.hstage`, `.hviewport`, `.pass-depth`, `.hstage-foot`,
   and a `display: grid !important` on `.pass-wall`) were removed with section
   3's markup: dead rules kept "as insurance" are what made this block's own
   deletion hazardous, and one of them was a `display: none` sitting directly
   under the paragraph forbidding them.

   The reduced-motion state of `.pass-wall` and its tilted `.pass` children is
   layout.css's, not this file's — it authors the tilt with independent
   `rotate` / `translate` deliberately so the tilt survives as a resting STATE
   here. A `transform: none !important` from this file would not have touched
   it, but claiming ownership of that box from here invites someone to add one.
   ========================================================================== */
@media (prefers-reduced-motion: reduce) {
    /* Parallax planes sit exactly where the static layout puts them.
       These are the nodes gsap-motion.js §2/§4/§4a writes an inline `y` to.
       An `!important` author declaration outranks a normal inline style, so
       this genuinely reverts GSAP's per-frame writes rather than merely
       competing with them — measured: 12 of 13 planes on `/` still carried a
       live inline transform after a mid-session preference flip while this
       block was being dropped by the parser, 0 afterwards. */
    .home-inner, .hero-name, .hero-lower, .hero-canvas-wrap,
    .section-head, .shape-plx, .shape-drift, .hero-fg-plx,
    .motif-parallax, .glow-sage, .glow-olive {
        transform: none !important;
        opacity: 1 !important;
        will-change: auto !important;
    }
    .hero-canvas-wrap { display: block !important; }

    /* Four additions to that list, all previously uncovered by ANY file:

       `.shape-drift`  — §2's ambient drift is the only tween on the page with
                         `repeat: -1`, so it is the one thing that keeps moving
                         forever after a mid-session flip rather than merely
                         resting somewhere wrong.
       `.glow-sage` /  — §4's background wash. Full-viewport blurred gradients
       `.glow-olive`     at z-index -2: the least noticeable motion here and
                         also the largest AREA of it, which is exactly the
                         combination a vestibular-sensitive reader reports as
                         "the page is swimming" without being able to point at
                         a moving object.
       `.motif-parallax` — §4's hero-motif <g>.

       None of the three carries an authored transform, so `none` is their true
       static state, not an approximation of it. Verified before adding: a
       `transform: none !important` on `.shape-field--travel` WOULD have been
       wrong (styles.css centres it with translateY(-50%)), which is why the
       drift group and not the field is the node listed here. */

    /* Contour density resets to the resting hairline: `--contour: 1`
       reproduces styles.css's stroke-widths exactly. */
    html, html.gsap-on { --contour: 1 !important; }

    /* The injected foreground plane is decoration, and decoration that
       cannot move has no reason to overlap the copy. Hidden rather than
       shown ONLY because it is aria-hidden ornament that never existed in
       the static markup — it carries no content. */
    .hero-fg { display: none !important; }

    /* Trip cadence marks rest at full height. */
    .trip-mark { transform: none !important; opacity: 1 !important; }
}
