/* ==========================================================================
   Digital3D Hero Cinema — post-processing stack for the 10 hero scenes
   8 full-box layers stacked above .hero-anim (z-index 20 → 27)
   Budget : 12 KB CSS, 0 JS, and AT MOST ONE NETWORK image for the whole page —
   a scene only fetches a plate when its own preset names one (Ocean, 55 KB).
   The fail2ban jail counts the VOLUME of static requests, not their status
   codes: never give a layer a second raster. .grade-grain therefore carries its
   texture as an inline data-URI (a few hundred bytes, 0 requests); any further
   texture must follow that route, not a new file under wwwroot.
   --------------------------------------------------------------------------
   NEUTRAL BY CONSTRUCTION — every layer takes its opacity from a --grade-*
   intensity whose fallback is 0, and each .hero-scene-N preset declares the
   whole set at 0. At opacity 0 a layer composites to the backdrop untouched,
   blend modes included: until a preset raises an intensity the hero renders
   exactly as it did before this file existed.

   Performance rules — do not "optimise" them away: no `will-change` on the
   static layers (a promoted full-box layer costs 2.3 MB, 9.2 MB at DPR 2), no
   `backdrop-filter` (it snapshots and blurs 1920×300 every frame), the only
   blur sits on the STATIC .grade-bloom so it rasterises once, and the only two
   animated layers — .grade-rays and .grade-grain — take their animation-name
   from --grade-rays-anim / --grade-grain-anim (default `none`), so nothing ticks
   while a scene leaves them off. Both animate transform ONLY: compositor work,
   never a repaint, and never an animated filter.

   TRAP — url() inside a custom property is resolved against the DOCUMENT, not
   against this stylesheet. --grade-plate-src is therefore root-absolute:
   url("/Images/hero/ocean-plate.webp"), never a relative "../Images/…".
   ========================================================================== */

/* Shared frame. The layers are siblings of .hero-anim, never its children:
   hero-animations.css halves the particles with :nth-child(2n), counted among
   all siblings — nesting here would flip the parity. */
.hero-stage > [class^="grade-"] {
    position: absolute;
    inset: 0;
    pointer-events: none;
}

/* 20. HAZE — aerial perspective: fogging the band around the horizon line is
   what pushes the far planes back. Static. */
.hero-stage > .grade-haze {
    z-index: 20;
    /* Fallback first: a browser without color-mix() drops the next one. */
    background: linear-gradient(to bottom, transparent 0%,
        var(--grade-haze-color, #b9c6d6) calc(var(--hero-horizon, 66%) - 10%),
        var(--grade-haze-color, #b9c6d6) var(--hero-horizon, 66%), transparent 100%);
    background: linear-gradient(to bottom, transparent 0%,
        color-mix(in srgb, var(--grade-haze-color, #b9c6d6) 55%, transparent) calc(var(--hero-horizon, 66%) - 10%),
        var(--grade-haze-color, #b9c6d6) var(--hero-horizon, 66%),
        color-mix(in srgb, var(--grade-haze-color, #b9c6d6) 20%, transparent) 100%);
    opacity: var(--grade-haze, 0);
}

/* 21. PLATE — photographic texture (grain / dust / halation). One raster plate
   per graded scene, named by --grade-plate-src: a scene that leaves the variable
   unset resolves to `none` and issues NO network request.
   --grade-plate-size defaults to `cover`, but a plate whose light is AIMED at a
   feature of the scene must be stretched, not cropped: `cover` on the 6.4:1 stage
   would keep only the middle 47 % of a 3:1 plate and throw its top away. Nothing
   in a pure grain/light texture has a shape to distort, so `100% 100%` is free. */
.hero-stage > .grade-plate {
    z-index: 21;
    background-image: var(--grade-plate-src, none);   /* root-absolute, see TRAP */
    background-size: var(--grade-plate-size, cover);
    background-repeat: no-repeat;
    mix-blend-mode: var(--grade-plate-blend, soft-light);
    opacity: var(--grade-plate, 0);
}

/* 22. BLOOM — halos hand-placed on the light sources of a scene. Static, so
   the blur is rasterised once. Blooms 2 and 3 default to `transparent`. */
.hero-stage > .grade-bloom {
    z-index: 22;
    background:
        radial-gradient(circle at var(--grade-bloom-x, 50%) var(--grade-bloom-y, 35%),
            var(--grade-bloom-color, transparent) 0%, transparent var(--grade-bloom-r, 18%)),
        radial-gradient(circle at var(--grade-bloom2-x, 20%) var(--grade-bloom2-y, 30%),
            var(--grade-bloom2-color, transparent) 0%, transparent var(--grade-bloom2-r, 12%)),
        radial-gradient(circle at var(--grade-bloom3-x, 80%) var(--grade-bloom3-y, 40%),
            var(--grade-bloom3-color, transparent) 0%, transparent var(--grade-bloom3-r, 12%));
    filter: blur(calc(var(--hero-h, 300px) * .06));
    mix-blend-mode: screen;
    opacity: var(--grade-bloom, 0);
}

/* 23. RAYS — volumetric light: five slits in a SINGLE layer (multi-background),
   faded out by a radial mask anchored on the light source. Only animated layer:
   ±0.8 % drift plus an opacity breath, both compositor-friendly. */
.hero-stage > .grade-rays {
    z-index: 23;
    background-image:
        linear-gradient(var(--grade-rays-angle, 100deg), transparent 12%, var(--grade-rays-color, #fff) 13%, transparent 15.5%),
        linear-gradient(var(--grade-rays-angle, 100deg), transparent 24%, var(--grade-rays-color, #fff) 25%, transparent 28%),
        linear-gradient(var(--grade-rays-angle, 100deg), transparent 39%, var(--grade-rays-color, #fff) 40%, transparent 41.5%),
        linear-gradient(var(--grade-rays-angle, 100deg), transparent 53%, var(--grade-rays-color, #fff) 54.5%, transparent 57%),
        linear-gradient(var(--grade-rays-angle, 100deg), transparent 70%, var(--grade-rays-color, #fff) 71%, transparent 74%);
    mask-image: radial-gradient(ellipse 65% 130% at var(--grade-rays-x, 50%) -15%, #000 0%, transparent 78%);
    mix-blend-mode: screen;
    opacity: var(--grade-rays, 0);
    /* `none` until a preset names the keyframes: nothing ticks while off. */
    animation: var(--grade-rays-anim, none) 17s ease-in-out infinite alternate;
}

@keyframes gradeRaysDrift {
    0%   { transform: translate3d(-0.8%, 0, 0); opacity: calc(var(--grade-rays, 0) * .78); }
    50%  { transform: translate3d(0, 0, 0);     opacity: var(--grade-rays, 0); }
    100% { transform: translate3d(0.8%, 0, 0);  opacity: calc(var(--grade-rays, 0) * .85); }
}

/* 24. TONE — the grade itself: cold shadows lifted toward a warm key. Static,
   and by far the biggest effect per byte of the whole stack. */
.hero-stage > .grade-tone {
    z-index: 24;
    background: linear-gradient(var(--grade-tone-angle, 155deg),
        var(--grade-shadow, #16213e) 0%, var(--grade-light, #ffd9a0) 100%);
    mix-blend-mode: var(--grade-tone-blend, soft-light);
    opacity: var(--grade-tone, 0);
}

/* 25. FRINGE — lens chromatic aberration, confined to the outer ~12 % where a
   real lens breaks down. Static, deliberately barely visible. */
.hero-stage > .grade-fringe {
    z-index: 25;
    background:
        radial-gradient(ellipse 12% 105% at 0% 50%, var(--grade-fringe-cool, #00d0ff) 0%, transparent 72%),
        radial-gradient(ellipse 12% 105% at 100% 50%, var(--grade-fringe-warm, #ff3b30) 0%, transparent 72%);
    mix-blend-mode: screen;
    opacity: var(--grade-fringe, 0);
}

/* 26. VIGNETTE — radii in %, on purpose: at a 6.4:1 aspect the horizontal one
   becomes huge in px, which is exactly what we want (centre and title safe
   area must not darken, only the far left/right edges do). */
.hero-stage > .grade-vignette {
    z-index: 26;
    background: radial-gradient(ellipse 75% 130% at 50% 45%, transparent 42%, #000 100%);
    opacity: var(--grade-vignette, 0);
}

/* 27. GRAIN — the film stock itself, hence LAST: haze/bloom/rays are the light,
   tone is the grade, fringe and vignette are the lens; grain is what the
   emulsion adds on top of all of them. It stays under .video-banner-overlay
   (z-index 1 in a stacking context ABOVE .hero-stage), so the title is clean.

   The one layer that must never be stretched. Grain is a HIGH-frequency signal:
   it only exists at native pixel scale, so it is tiled at a fixed px size and
   never sized in %. That is the whole difference with .grade-plate, whose light
   is low frequency and is meant to stretch onto the features of the scene — a
   1536×512 plate squeezed to 1920×300 (0.59 in Y) keeps its glow and loses its
   grain entirely, which is why the two live in separate layers.

   Zero network cost: the texture is a STATIC feTurbulence in a data-URI, so it
   never touches the fail2ban jail's request budget. Static is not a detail —
   the 7 filters of HeroFilters are the CPU hot spot of the hero, and a live 8th
   turbulence would be the worst possible addition. This one rasterises once and
   is then served from the image cache like any other bitmap.
   Inside the data-URI: color-interpolation-filters='sRGB' keeps the noise
   centred on 0.5 (the linearRGB default would land it near 0.74 and brighten
   the frame), filterUnits='userSpaceOnUse' pins the filter region to the tile so
   stitchTiles='stitch' actually stitches, the feColorMatrix desaturates and
   forces alpha to 1 (opaque mid-grey, not translucent black), and the
   feComponentTransfer expands contrast AROUND 0.5 (slope 1.9, intercept -0.45)
   so the layer stays neutral where the noise is neutral. The `#` of the internal
   filter reference MUST be %23 or the URI is cut at a fragment.

   `overlay`, not `screen`: grain has to modulate in BOTH directions. screen can
   only add light — right for .grade-plate, wrong here. */
.hero-stage > .grade-grain {
    z-index: 27;
    /* Overshoot the frame: the drift below shifts the layer by a few px and
       inset:0 would expose a bare edge. .video-banner clips the overshoot. */
    inset: -8px;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='128' height='128'%3E%3Cfilter id='g' filterUnits='userSpaceOnUse' x='0' y='0' width='128' height='128' color-interpolation-filters='sRGB'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.72' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='.33 .33 .33 0 0 .33 .33 .33 0 0 .33 .33 .33 0 0 0 0 0 0 1'/%3E%3CfeComponentTransfer%3E%3CfeFuncR type='linear' slope='1.9' intercept='-.45'/%3E%3CfeFuncG type='linear' slope='1.9' intercept='-.45'/%3E%3CfeFuncB type='linear' slope='1.9' intercept='-.45'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='128' height='128' filter='url(%23g)'/%3E%3C/svg%3E");
    background-size: var(--grade-grain-size, 128px) var(--grade-grain-size, 128px);
    background-repeat: repeat;
    mix-blend-mode: overlay;
    opacity: var(--grade-grain, 0);
    /* `none` until a preset names the keyframes: nothing ticks while off. */
    animation: var(--grade-grain-anim, none) 1s steps(1, end) infinite;
}

/* Moving grain is what separates "film" from "frozen noise". Nothing but a
   translate on an already-rasterised tile: compositor only, zero repaint, and
   no `will-change` (the animation itself promotes the layer for its duration). */
@keyframes gradeGrainShift {
    0%   { transform: translate3d(0, 0, 0); }
    20%  { transform: translate3d(-5px, 3px, 0); }
    40%  { transform: translate3d(4px, -4px, 0); }
    60%  { transform: translate3d(-3px, -2px, 0); }
    80%  { transform: translate3d(5px, 4px, 0); }
    100% { transform: translate3d(0, 0, 0); }
}

/* ==========================================================================
   PRESETS — one block per scene, holding the full variable set. Everything
   ships at intensity 0, so the colours are inert placeholders carrying the
   intent of the next step: grading a scene means raising its numbers HERE,
   never touching the engine above. The secondary bloom variables are left out
   of the presets: they default to `transparent` and paint nothing.
   ========================================================================== */

/* 1 — Aurora (graded)
   --hero-horizon is MEASURED, not chosen: the far crest line is y=224 of a
   `0 0 2400 300` viewBox stretched with preserveAspectRatio="none" over the full
   height, i.e. 224/300 = 74.7 % at every breakpoint. .grade-haze bands on it.
   Bloom and rays are anchored on the REAL light of the scene — the two curtain
   columns, whose mask holds them at ~13 % and ~87 % of the width — never on the
   centre, which the same mask keeps dim so the title reads. */
.hero-scene-1 {
    --hero-horizon: 74.7%;
    /* Kept low: the sky here is nearly black and the curtains are the only light.
       At Ocean's .34 the haze turned the whole lower sky into a flat grey fog and
       swallowed the far range — the same failure mode, one scene later. */
    --grade-haze: .15; --grade-haze-color: #2f5a78;
    --grade-plate: .17;
    --grade-plate-src: url("/Images/hero/aurora-plate.webp");   /* root-absolute, see TRAP */
    --grade-plate-blend: screen;
    --grade-plate-size: 100% 100%;
    /* Three halos, all on real sources: the green column, the violet one, and the
       pool of aurora light on the snow under the green column. The curtains are
       already `screen` + blurred, so these are DELIBERATELY restrained — stacking
       a second glow on a glow is how a scene loses its blacks. */
    --grade-bloom: .38;
    --grade-bloom-x: 13%; --grade-bloom-y: 44%; --grade-bloom-r: 25%; --grade-bloom-color: #48ffc8;
    --grade-bloom2-x: 87%; --grade-bloom2-y: 42%; --grade-bloom2-r: 23%; --grade-bloom2-color: #b478ff;
    --grade-bloom3-x: 12%; --grade-bloom3-y: 84%; --grade-bloom3-r: 15%; --grade-bloom3-color: #3fd8b0;
    /* Nearly off: the front curtain already carries carved rays, and this layer's
       five slits would beat against them. It is here for the slow breath only. */
    --grade-rays: .05; --grade-rays-x: 13%; --grade-rays-color: #d6ffee; --grade-rays-anim: gradeRaysDrift;
    --grade-tone: .28; --grade-tone-blend: soft-light; --grade-shadow: #071535; --grade-light: #8ef0d4;
    --grade-fringe: .05; --grade-vignette: .40;
    /* Less than Ocean's .35: this frame has large bright areas in the wings, and
       grain reads twice as loud on light as it does on a black sky. */
    --grade-grain: .28; --grade-grain-anim: gradeGrainShift;
    /* Consumed by the overlay/readability step, inert here. */
    --scrim: .42; --scene-accent: #6ff0c8;
}

/* 2 — Sakura */
.hero-scene-2 {
    --grade-haze: 0; --grade-haze-color: #f2d3e0; --grade-plate: 0;
    --grade-bloom: 0; --grade-bloom-x: 70%; --grade-bloom-y: 25%; --grade-bloom-r: 18%; --grade-bloom-color: #ffd0e0;
    --grade-rays: 0; --grade-rays-x: 70%; --grade-rays-color: #fff0f5; --grade-rays-anim: none;
    --grade-tone: 0; --grade-shadow: #4a2a4a; --grade-light: #ffe0d0;
    --grade-fringe: 0; --grade-vignette: 0; --grade-grain: 0; --grade-grain-anim: none;
}

/* 3 — Ocean (graded: pilot scene of the doctrine)
   --hero-horizon is MEASURED, not chosen: the waterline is y=220 of a
   `0 0 1200 300` viewBox stretched with preserveAspectRatio="none" over the full
   height, i.e. 220/300 = 73.3 % at every breakpoint. .grade-haze bands on it.
   Bloom and rays are anchored on the REAL moon of the scene: a disc of
   .3 × --hero-h, top .09 × --hero-h, right 14 % → centre at ~83 % / 24 %. */
.hero-scene-3 {
    --hero-horizon: 73.3%;
    /* Haze and tone were dialled back (.34 → .18, .40 → .30) once the water got
       its own texture: at the former intensities the pair washed the whole frame
       to a flat grey-brown fog, which erased the horizon on the moon side and
       swallowed the island ridge. The night is meant to be CLEAR and blue. */
    --grade-haze: .18; --grade-haze-color: #4d82ac;
    /* The only raster of the whole stack, and the only one Ocean pays for: an
       abstract grain/dust/halation plate, near-black background, its warm glow
       authored at ~88 %/14 % so it lands on the moon (83 %/24 %) once stretched.
       `screen` because the plate is black-based — it can only ADD light, never
       darken, so the vector scene keeps every one of its own values. */
    --grade-plate: .16;
    --grade-plate-src: url("/Images/hero/ocean-plate.webp");   /* root-absolute, see TRAP */
    --grade-plate-blend: screen;
    --grade-plate-size: 100% 100%;
    --grade-bloom: .50; --grade-bloom-x: 83%; --grade-bloom-y: 24%; --grade-bloom-r: 26%; --grade-bloom-color: #ffe9b0;
    --grade-rays: .09; --grade-rays-x: 83%; --grade-rays-color: #dcf0ff; --grade-rays-anim: gradeRaysDrift;
    --grade-tone: .30; --grade-tone-blend: soft-light; --grade-shadow: #06263f; --grade-light: #ffd9a0;
    --grade-fringe: .05; --grade-vignette: .40;
    /* The plate above carries the LIGHT of the film stock; this carries its
       TEXTURE, at pixel scale. Dialled to the point where it reads as emulsion
       on the sky without being seen as noise — on a night scene very little
       goes a long way, the modulation is relative to a nearly black base. */
    --grade-grain: .35; --grade-grain-anim: gradeGrainShift;
    /* Consumed by the overlay/readability step, inert here. */
    --scrim: .44; --scene-accent: #8fd6ff;
}

/* 4 — Storm (graded)
   --hero-horizon is MEASURED: the waterline is y=220 of a `0 0 1200 300` viewBox
   stretched with preserveAspectRatio="none" over the full height, i.e. 73.3 % at
   every breakpoint — the same number Ocean holds, and the value the boat hangs
   off in hero-animations.css.
   The bloom sits on the LIGHT SLOT under the ceiling (~70 % of the height), which
   is this scene's only steady source. It is deliberately NOT on the lightning:
   an intermittent source cannot carry a static halo — the halo would be there for
   the whole cycle and the flash for two frames of it. */
.hero-scene-4 {
    --hero-horizon: 73.3%;
    /* Rain between the eye and the horizon is exactly what aerial perspective
       describes — but at .24 it fogged sky and sea into ONE grey band, erased the
       waterline and swallowed the boat. Ocean learnt the same lesson at .34. */
    --grade-haze: .13; --grade-haze-color: #6d7a90;
    --grade-plate: .13;
    --grade-plate-src: url("/Images/hero/storm-plate.webp");   /* root-absolute, see TRAP */
    --grade-plate-blend: screen;
    --grade-plate-size: 100% 100%;
    --grade-bloom: .22;
    --grade-bloom-x: 30%; --grade-bloom-y: 71%; --grade-bloom-r: 30%; --grade-bloom-color: #b3c2da;
    --grade-bloom2-x: 79%; --grade-bloom2-y: 70%; --grade-bloom2-r: 25%; --grade-bloom2-color: #9fb2cf;
    /* Rain shafts. The one scene where the five slits of .grade-rays are literal
       rather than decorative — and the default 100 deg puts them within a couple
       of degrees of the 12 deg the .rain drops fall at. */
    --grade-rays: .11; --grade-rays-x: 34%; --grade-rays-color: #cdd8ea; --grade-rays-anim: gradeRaysDrift;
    --grade-tone: .28; --grade-tone-blend: soft-light; --grade-shadow: #0b1019; --grade-light: #9db2ce;
    --grade-fringe: .04; --grade-vignette: .46;
    /* The heaviest grain of the three graded scenes: on a storm the texture reads
       as weather rather than as noise, and the frame is dark enough to take it. */
    --grade-grain: .34; --grade-grain-anim: gradeGrainShift;
    /* Consumed by the overlay/readability step, inert here. */
    --scrim: .46; --scene-accent: #a8c4e8;
}

/* 5 — Nebula (graded)
   --hero-horizon is not a landscape line here: deep space has none. It is the
   galactic dust lane at 68 %, the only band .grade-haze can sensibly fog, and
   the scene's stability is proven on its two remarkable OBJECTS instead — the
   black hole (centre 87 % / 28 %) and the planet (centre 13 % / 64 %), both
   anchored by their centre in hero-animations.css.
   Bloom and rays are anchored on those REAL sources — the accretion disc, the
   planet, and the pink cloud core at 10 % / 24 % — never on the centre, which
   the backdrop keeps near-black so the title reads. */
.hero-scene-5 {
    --hero-horizon: 68%;
    /* Barely there, and that is the point: vacuum has no aerial perspective.
       This is the third scene where a haze above ~.2 flattens everything into
       one grey band; on a frame whose middle is deliberately near-black it
       would be the single fastest way to lose the composition. */
    --grade-haze: .08; --grade-haze-color: #4a3570;
    --grade-plate: .15;
    --grade-plate-src: url("/Images/hero/nebula-plate.webp");   /* root-absolute, see TRAP */
    --grade-plate-blend: screen;
    --grade-plate-size: 100% 100%;
    /* Three halos on the three real sources. Restrained on purpose: the clouds
       are already `screen`-blended light, and stacking a glow on a glow is how
       a night sky loses its blacks. */
    --grade-bloom: .34;
    --grade-bloom-x: 87%; --grade-bloom-y: 28%; --grade-bloom-r: 17%; --grade-bloom-color: #c9a2ff;
    --grade-bloom2-x: 13%; --grade-bloom2-y: 64%; --grade-bloom2-r: 14%; --grade-bloom2-color: #ff8a4a;
    --grade-bloom3-x: 10%; --grade-bloom3-y: 22%; --grade-bloom3-r: 12%; --grade-bloom3-color: #ff5fae;
    /* Nearly off. There is no atmosphere out here for a light shaft to scatter
       in; the layer is kept for its slow breath on the black hole side only. */
    --grade-rays: .05; --grade-rays-x: 87%; --grade-rays-color: #d8c6ff; --grade-rays-anim: gradeRaysDrift;
    --grade-tone: .26; --grade-tone-blend: soft-light; --grade-shadow: #0b0620; --grade-light: #ff9ad0;
    --grade-fringe: .06; --grade-vignette: .42;
    --grade-grain: .30; --grade-grain-anim: gradeGrainShift;
    /* Consumed by the overlay/readability step, inert here. */
    --scrim: .45; --scene-accent: #c9a6ff;
}

/* 6 — Zen */
.hero-scene-6 {
    --grade-haze: 0; --grade-haze-color: #e6dcc8; --grade-plate: 0;
    --grade-bloom: 0; --grade-bloom-x: 75%; --grade-bloom-y: 26%; --grade-bloom-r: 18%; --grade-bloom-color: #ffe6c0;
    --grade-rays: 0; --grade-rays-x: 75%; --grade-rays-color: #fff6e0; --grade-rays-anim: none;
    --grade-tone: 0; --grade-shadow: #3a4038; --grade-light: #f5e0b8;
    --grade-fringe: 0; --grade-vignette: 0; --grade-grain: 0; --grade-grain-anim: none;
}

/* 7 — Cyberpunk (graded)
   --hero-horizon is MEASURED: the skyline base — where the three carriers stand
   and the grid floor begins — is pinned at 66 % of --hero-h by construction in
   hero-animations.css, so it lands there at every breakpoint. .grade-haze bands
   on it, which on this scene reads as the city's neon smog.
   Bloom and rays are anchored on the REAL light of the scene, the neon sun,
   whose centre is at 16 % of the width and whose brightest edge is its base, on
   the horizon: 16 % / 62 %. Never on the centre — that is exactly where the sun
   used to sit, straight behind the title. */
.hero-scene-7 {
    --hero-horizon: 66%;
    /* Neon smog, and the one scene of the four where a visible haze is IN the
       subject rather than against it. Still kept under Ocean's .18: the skyline
       silhouette is the composition, and fogging it is losing it. */
    --grade-haze: .14; --grade-haze-color: #7a2a72;
    --grade-plate: .17;
    --grade-plate-src: url("/Images/hero/cyberpunk-plate.webp");   /* root-absolute, see TRAP */
    --grade-plate-blend: screen;
    --grade-plate-size: 100% 100%;
    /* Halo of the sun, its bounce on the grid floor just below, and the cool
       city glow the near skyline throws in the opposite wing. */
    --grade-bloom: .46;
    --grade-bloom-x: 16%; --grade-bloom-y: 62%; --grade-bloom-r: 22%; --grade-bloom-color: #ff5c96;
    --grade-bloom2-x: 16%; --grade-bloom2-y: 84%; --grade-bloom2-r: 13%; --grade-bloom2-color: #ff2f80;
    --grade-bloom3-x: 87%; --grade-bloom3-y: 58%; --grade-bloom3-r: 15%; --grade-bloom3-color: #3fa8e8;
    /* The best-paying effect of this scene: a wet neon city is ALL volumetric
       light. Anchored on the sun, so the shafts come from the left wing and die
       out well before the title — the radial mask of .grade-rays sees to that. */
    --grade-rays: .10; --grade-rays-x: 16%; --grade-rays-color: #ffc0dc; --grade-rays-anim: gradeRaysDrift;
    --grade-tone: .30; --grade-tone-blend: soft-light; --grade-shadow: #0a0420; --grade-light: #ff8ac0;
    --grade-fringe: .06; --grade-vignette: .46;
    --grade-grain: .30; --grade-grain-anim: gradeGrainShift;
    /* Consumed by the overlay/readability step, inert here. */
    --scrim: .46; --scene-accent: #ff6fae;
}

/* 8 — Forest */
.hero-scene-8 {
    --grade-haze: 0; --grade-haze-color: #b8d0a0; --grade-plate: 0;
    --grade-bloom: 0; --grade-bloom-x: 30%; --grade-bloom-y: 18%; --grade-bloom-r: 20%; --grade-bloom-color: #e8ffb0;
    --grade-rays: 0; --grade-rays-x: 30%; --grade-rays-color: #eaffd0; --grade-rays-anim: none;
    --grade-tone: 0; --grade-shadow: #0e2418; --grade-light: #ffe9a0;
    --grade-fringe: 0; --grade-vignette: 0; --grade-grain: 0; --grade-grain-anim: none;
}

/* 9 — Desert */
.hero-scene-9 {
    --grade-haze: 0; --grade-haze-color: #e8c89a; --grade-plate: 0;
    --grade-bloom: 0; --grade-bloom-x: 55%; --grade-bloom-y: 30%; --grade-bloom-r: 22%; --grade-bloom-color: #ffcf80;
    --grade-rays: 0; --grade-rays-x: 55%; --grade-rays-color: #ffe8c0; --grade-rays-anim: none;
    --grade-tone: 0; --grade-shadow: #4a2c18; --grade-light: #ffcf80;
    --grade-fringe: 0; --grade-vignette: 0; --grade-grain: 0; --grade-grain-anim: none;
}

/* 10 — Mesh */
.hero-scene-10 {
    --grade-haze: 0; --grade-haze-color: #1c2b4a; --grade-plate: 0;
    --grade-bloom: 0; --grade-bloom-x: 50%; --grade-bloom-y: 45%; --grade-bloom-r: 20%; --grade-bloom-color: #4fa8ff;
    --grade-rays: 0; --grade-rays-x: 50%; --grade-rays-color: #cfe4ff; --grade-rays-anim: none;
    --grade-tone: 0; --grade-shadow: #081426; --grade-light: #7fc4ff;
    --grade-fringe: 0; --grade-vignette: 0; --grade-grain: 0; --grade-grain-anim: none;
}

/* ==========================================================================
   CAMERA — pointer parallax and dolly. Consumption side only: the pointer is
   published by js/hero.js as two UNITLESS numbers in [-1, 1] on .hero-stage,
   and everything below is CSS. The JS writes ONE element; the depth fan-out is
   var() inheritance, which is what a per-layer write would emulate by hand.

   ADDITIVE BY CONSTRUCTION — the offsets only exist under `.hero-parallax`, a
   class js/hero.js adds when (and only when) it installs its listener. Without
   JS or under 768 px, not one declaration below applies: no `translate` appears
   on any layer, so no layer gains a stacking context it did not already have.
   That is what keeps the prerendered frame — the one the crawlers and the SSR
   capture see — byte-identical to before. (Reduced motion is NOT one of those
   cases any more: by product decision the parallax is installed there too, and
   would only step aside if HONOR_REDUCED_MOTION were flipped back on.)

   `translate`, NOT `transform`. Most of these layers already animate transform
   in @keyframes (islandScroll, gullSoar, sparkleFade, mglintShimmer…); writing
   a transform here would REPLACE the animated one. The autonomous property
   composes instead — translate ∘ rotate ∘ scale ∘ transform — so no scene has
   to be re-wrapped in parallax containers.
   ========================================================================== */
.hero-stage {
    --px: 0;
    --py: 0;
    /* Amplitude tied to --hero-h, so the effect keeps its proportion at all four
       breakpoints: 5 % of the hero height, i.e. ~15 px at 300 px. Parallax that
       is NOTICED as an effect has failed — this buys depth, not movement.
       Y is halved on top of that: a 6.4:1 frame has almost no vertical room, and
       a visible vertical slide would expose the top or bottom edge of a layer. */
    --parallax-amp: calc(var(--hero-h, 300px) * .05);
}

/* Depth per layer. Only the CALIBRATED scenes declare --depth; the others resolve
   to 0 and simply do not move. Ocean (3), Aurora (1) and Storm (4) are calibrated.
   The ordering is the composition's own: stars sit at infinity, the moon and the
   island ridge on the far plane, the water and everything floating ON it share
   ONE value (glints that drifted against their own waves would read as a bug),
   the gulls fly between, and the out-of-focus swell is the near plane.
   The moon and its reflection share a depth on purpose: hero-animations.css
   aligns their centres by construction, and two depths would break that. */
.hero-anim-ocean .star { --depth: .06; }
.hero-anim-ocean .moon,
.hero-anim-ocean .moon-reflection,
.hero-anim-ocean .island-layer { --depth: .12; }
.hero-anim-ocean svg.bg,
.hero-anim-ocean .sparkle,
.hero-anim-ocean .mglint { --depth: .30; }
.hero-anim-ocean .gull { --depth: .45; }
.hero-anim-ocean .swell-fg { --depth: .60; }

.hero-stage.hero-parallax .hero-anim-ocean .star,
.hero-stage.hero-parallax .hero-anim-ocean .moon,
.hero-stage.hero-parallax .hero-anim-ocean .moon-reflection,
.hero-stage.hero-parallax .hero-anim-ocean .island-layer,
.hero-stage.hero-parallax .hero-anim-ocean .sparkle,
.hero-stage.hero-parallax .hero-anim-ocean .mglint,
.hero-stage.hero-parallax .hero-anim-ocean .gull {
    translate: calc(var(--px, 0) * var(--depth, 0) * var(--parallax-amp))
               calc(var(--py, 0) * var(--depth, 0) * var(--parallax-amp) * .5);
}

/* The two layers that ALREADY own `translate` — for their own centring, not for
   an animation (svg.bg and .swell-fg are re-framed from 50 % with a -50 %
   shift). The parallax offset is ADDED to it; dropping the -50 % here would
   shove the whole composition half a frame to the right. */
.hero-stage.hero-parallax .hero-anim-ocean svg.bg,
.hero-stage.hero-parallax .hero-anim-ocean .swell-fg {
    translate: calc(-50% + var(--px, 0) * var(--depth, 0) * var(--parallax-amp))
               calc(var(--py, 0) * var(--depth, 0) * var(--parallax-amp) * .5);
}

/* Aurora. Same ordering logic as Ocean: stars at infinity, the two curtains just
   in front of them (they are sky, not scenery), the three ground planes receding
   toward the viewer, and the snow — which falls between the eye and everything —
   as the near plane. The curtains and the ranges already animate `transform`
   (auroraDrift*, mountainScroll); `translate` composes with it instead of
   replacing it, which is the whole reason this file uses the autonomous property. */
.hero-anim-aurora .star,
.hero-anim-aurora .shooting-star { --depth: .06; }
.hero-anim-aurora .aurora-curtain { --depth: .10; }
.hero-anim-aurora .m-far { --depth: .16; }
.hero-anim-aurora .m-mid { --depth: .26; }
.hero-anim-aurora .m-near { --depth: .36; }
.hero-anim-aurora .snowflake { --depth: .50; }

/* Storm. The sea and the boat share ONE value on purpose: a hull that drifted
   against its own waves would read as a bug, exactly as Ocean's glints would. */
.hero-anim-storm .cl-far { --depth: .10; }
.hero-anim-storm .cl-mid { --depth: .20; }
.hero-anim-storm .cl-near { --depth: .30; }
.hero-anim-storm svg.bg,
.hero-anim-storm .boat { --depth: .26; }
.hero-anim-storm .storm-gull { --depth: .40; }
.hero-anim-storm .rain { --depth: .55; }

.hero-stage.hero-parallax .hero-anim-aurora .star,
.hero-stage.hero-parallax .hero-anim-aurora .shooting-star,
.hero-stage.hero-parallax .hero-anim-aurora .aurora-curtain,
.hero-stage.hero-parallax .hero-anim-aurora .mountain-layer,
.hero-stage.hero-parallax .hero-anim-aurora .snowflake,
.hero-stage.hero-parallax .hero-anim-storm .cloud-layer,
.hero-stage.hero-parallax .hero-anim-storm .boat,
.hero-stage.hero-parallax .hero-anim-storm .storm-gull,
.hero-stage.hero-parallax .hero-anim-storm .rain {
    translate: calc(var(--px, 0) * var(--depth, 0) * var(--parallax-amp))
               calc(var(--py, 0) * var(--depth, 0) * var(--parallax-amp) * .5);
}

/* Storm's svg.bg ALREADY owns `translate` for its own centring (-50 %), like
   Ocean's: the offset is added to it, never substituted for it. */
.hero-stage.hero-parallax .hero-anim-storm svg.bg {
    translate: calc(-50% + var(--px, 0) * var(--depth, 0) * var(--parallax-amp))
               calc(var(--py, 0) * var(--depth, 0) * var(--parallax-amp) * .5);
}

/* Nebula. There is no ground plane out here, so the ordering is the optical
   one: the star field is at infinity, the coloured clouds are the far volume,
   the black hole and the planet are the two SUBJECTS, and the comets pass in
   front of everything. .nebula-cloud animates transform (nebulaSwirl), .planet
   animates it too (planetDrift), and .comet rides an offset-path — `translate`
   composes with all three, which is why this file never writes `transform`. */
.hero-anim-nebula .star { --depth: .04; }
.hero-anim-nebula .nebula-cloud { --depth: .09; }
.hero-anim-nebula .blackhole { --depth: .16; }
.hero-anim-nebula .planet { --depth: .22; }
.hero-anim-nebula .comet { --depth: .34; }

/* Cyberpunk. The three skyline planes ARE the depth of the scene, so they take
   the ordering straight from their scroll speeds (120 s / 70 s / 45 s); the sun
   sits behind all of them, the aircraft fly between the city and the eye, and
   the rain is the near plane. .grid-floor is deliberately absent: it is pinned
   to the bottom edge of the frame, and a vertical offset there would open a bare
   strip under it. The sun's own `transform: translateX(-50%)` is untouched —
   `translate` and `transform` compose, they do not replace each other. */
.hero-anim-cyberpunk .neon-sun { --depth: .06; }
.hero-anim-cyberpunk .skyline-far { --depth: .12; }
.hero-anim-cyberpunk .skyline-mid { --depth: .22; }
.hero-anim-cyberpunk .skyline-near { --depth: .32; }
.hero-anim-cyberpunk .hovercraft { --depth: .46; }
.hero-anim-cyberpunk .cyber-rain { --depth: .60; }

.hero-stage.hero-parallax .hero-anim-nebula .star,
.hero-stage.hero-parallax .hero-anim-nebula .nebula-cloud,
.hero-stage.hero-parallax .hero-anim-nebula .blackhole,
.hero-stage.hero-parallax .hero-anim-nebula .planet,
.hero-stage.hero-parallax .hero-anim-nebula .comet,
.hero-stage.hero-parallax .hero-anim-cyberpunk .neon-sun,
.hero-stage.hero-parallax .hero-anim-cyberpunk .skyline-layer,
.hero-stage.hero-parallax .hero-anim-cyberpunk .hovercraft,
.hero-stage.hero-parallax .hero-anim-cyberpunk .cyber-rain {
    translate: calc(var(--px, 0) * var(--depth, 0) * var(--parallax-amp))
               calc(var(--py, 0) * var(--depth, 0) * var(--parallax-amp) * .5);
}

/* DOLLY — the camera move, in pure CSS: it is a fixed loop with no input, so a
   frame of JavaScript would buy nothing. It rides the autonomous `scale`, and an
   off-centre transform-origin turns that ONE property into zoom AND drift (a
   scale about a non-centred origin translates), so nothing else has to animate.

   On the near plane ONLY, and that is the whole design:
   - never .hero-stage nor .hero-anim — continuously scaling a subtree of 100+
     animated elements re-rasterises all of it on every frame;
   - never svg.bg — its waterline is MEASURED at 73.3 % and --grade-haze,
     .moon-reflection and .mglint are all calibrated on it; scaling the water
     would walk the horizon away from four things that follow it by number;
   - .swell-fg is the one plane that can take it: static content (so it
     rasterises once and only the compositor transform varies), already blurred,
     already overscanned by 4 %, and nothing is calibrated against its edges.
   Because it is a CSS animation on a descendant of .hero-anim, the (now disarmed)
   prefers-reduced-motion rule of hero-animations.css is what would stop it again,
   the day HONOR_REDUCED_MOTION is flipped back on in js/hero.js. */
.hero-anim-ocean .swell-fg {
    transform-origin: 38% 100%;
    animation: heroDolly 40s ease-in-out infinite alternate;
}

@keyframes heroDolly {
    from { scale: 1; }
    to   { scale: 1.06; }
}

@media (max-width: 768px) {
    /* Only haze, plate, tone and vignette survive: the others are pure
       decoration and each full-box layer costs 9.2 MB of GPU memory at DPR 2.
       Grain goes too — it is the layer that gains the least on a small screen
       (a DPR 2 phone doubles the tile, so the texture is half as fine) for the
       cost of a promoted, animated full-box layer. */
    .hero-stage > .grade-bloom,
    .hero-stage > .grade-rays,
    .hero-stage > .grade-fringe,
    .hero-stage > .grade-grain { display: none; }

    /* The parallax already switches itself off here (js/hero.js never installs
       its listener under 768 px, so `.hero-parallax` is never added). The dolly
       is CSS and would keep running: a phone gets neither. */
    .hero-anim-ocean .swell-fg { animation: none; }
}

/* PRODUCT DECISION — see the same note in hero-animations.css: the hero animates for
   every visitor, reduced-motion included. The rule below is intact but DISARMED, gated
   on `.hero-honor-reduced-motion` (added by js/hero.js only when HONOR_REDUCED_MOTION is
   true, currently false). Not an oversight, and reversible by that single flag. */
@media (prefers-reduced-motion: reduce) {
    /* The rest of the stack is static; the rays drift and the grain shifts.
       The grain layer is kept — frozen, it is still film texture, and the
       texture is not the motion. */
    .hero-honor-reduced-motion .hero-stage > .grade-rays,
    .hero-honor-reduced-motion .hero-stage > .grade-grain {
        animation: none !important;
        transform: none !important;
    }
}
