/**
 * PARALLAX — the CSS half of the contract.
 *
 * This stylesheet animates NOTHING on its own. It declares neutral defaults for
 * every `--px-*` custom property and the composition rules that turn those
 * properties into `translate` / `rotate` / `scale` / `transform` / `filter` /
 * `opacity`. `assets/js/ractro-parallax.js` writes the variables; every rule
 * here is a pure consumer. An element with `ractro-px` and no driver renders
 * exactly as it does without parallax — that is the whole point of the defaults.
 *
 * Read docs/parallax-architecture-decisions.md first. D1-D7 below are its
 * decisions, each settled by measurement in Chrome, and none of them is
 * re-litigated here.
 *
 * ---------------------------------------------------------------------------
 * ONE CLASS PER COMPOSED PROPERTY. This is the organising rule of the file.
 * ---------------------------------------------------------------------------
 * `.ractro-px` alone declares custom properties and nothing else, so it has no
 * rendering side effects at all. Each *shorthand or transform* property is
 * gated behind its own author-derived class, and is emitted only for elements
 * that actually animate that property:
 *
 *   .ractro-px--move    -> translate:  (x / y / mouse channels)
 *   .ractro-px--rot     -> rotate:
 *   .ractro-px--scale   -> scale:
 *   .ractro-px--3d      -> transform:  (skew + perspective/rotateX/rotateY/translateZ)
 *   .ractro-px--filter  -> filter:     (blur + colour shift)
 *   .ractro-px--fade    -> opacity:
 *
 * TWO reasons, and both are load-bearing:
 *
 * 1. D5, MEASURED: transform, translate, scale, rotate, filter and even a bare
 *    `will-change: transform` each hijack `position: fixed` descendants — a
 *    fixed child moved 0 -> 200px the moment its ancestor gained one. The front
 *    end is safe because sticky uses `position: sticky`, but the CANVAS is not:
 *    ractro-sticky.js falls back to `position: fixed` in the editor. Painting
 *    `translate: 0px 0px` on every parallax element would create a containing
 *    block nobody asked for, on every element, forever. Gated, an element pays
 *    only for the channels its author switched on. The port spec already
 *    reasons exactly this way for `--filter`; this file applies that reasoning
 *    to all six.
 *
 *    RE-MEASURED against this stylesheet, a `position: fixed` child of a
 *    200x200 box at top 300px / left 40px, reading the child's own rect:
 *
 *        .ractro-px                                   ->    0, 0    free
 *        .ractro-px.ractro-px--move  (both vars 0)    ->  300, 40   hijacked
 *        .ractro-px.ractro-px--filter                 ->  300, 40   hijacked
 *        .ractro-px.ractro-px--active                 ->  300, 40   hijacked
 *        .ractro-px.ractro-px--active.ractro-px--idle ->    0, 0    released
 *
 *    So a neutral `translate: 0px 0px` and a bare `will-change` cost exactly as
 *    much as a real transform, and only the gating and the `--idle` release
 *    buy the element back.
 *
 * 2. Three of the composition rules read `--ractro-rot`, `--ractro-scale` and
 *    `--ractro-op`, and the schema does NOT write those variables yet — today
 *    `global_rotate` emits a literal `rotate: 30deg`, `global_scale` a literal
 *    `scale: calc(120 / 100)` and `global_opacity` a literal `opacity: …`
 *    (trait-ractro-widget-manager-schema.php, the Motion and Filters groups).
 *    Until those three selectors move to the var+property form the port spec
 *    §4a/§4b specifies, a composition rule reading the missing variable falls
 *    back to neutral and CLOBBERS the author's static value. Gating confines
 *    that to an element that both has a static rotate/scale/opacity AND turned
 *    on the matching parallax channel, instead of every parallax element. It is
 *    a real (small) regression until the schema lands — see the report.
 *    `--ractro-tx` / `--ractro-ty` / `--ractro-skx` / `--ractro-sky` already
 *    exist, so translate and skew compose correctly today.
 *
 * ---------------------------------------------------------------------------
 * SPECIFICITY: the tripled-class idiom, for the same reason sticky doubles.
 * ---------------------------------------------------------------------------
 * `assets/css/widgets/ractro-sticky.css:35-43` writes
 * `.ractro-sticky.ractro-sticky--top` rather than the single class, because a
 * stylesheet that loads later would otherwise win on order alone. The same
 * hazard is worse here. The compiled page stylesheet is a `<link>` PRINTED from
 * `wp_head` (never enqueued — CLAUDE.md forbids "tidying" that), it uses no
 * `!important`, and it wins purely on specificity plus load order. Its element
 * rule is `.ractro-element-<id>` = (0,1,0) and its hover rule
 * `.ractro-element-<id>:hover` = (0,2,0). This file is an ordinary enqueued
 * stylesheet whose position relative to that printed link is not guaranteed, so
 * a (0,1,0) or (0,2,0) rule here would win or lose depending on load order —
 * i.e. non-deterministically. Tripling to (0,3,0) beats both, always.
 *
 * `!important` would also work and is forbidden (D2): it would outrank
 * `.ractro-sticky--stuck`'s `transform`, breaking hide-on-scroll — fixing one
 * conflict by creating another. The custom properties themselves still cascade
 * normally, so a `:hover` value of `--ractro-rot` is picked up automatically
 * and hover states keep working.
 *
 * ---------------------------------------------------------------------------
 * CLASSES DEFINED HERE
 * ---------------------------------------------------------------------------
 * Author-derived (emitted by BOTH Ractro_Parallax::attrs() and behaviourClasses()
 * in src/lib/behaviourAttrs.js — React rewrites className wholesale on the canvas
 * wrapper, so a class only the driver adds survives milliseconds):
 *   ractro-px  --move  --rot  --scale  --3d  --filter  --fade
 *   ractro-px--bg  --layers  --clip  --host
 * Driver-added:
 *   ractro-px--active   only while the element is on screen and being written to
 *   ractro-px--idle     off screen; keeps its last frame, drops will-change
 *   ractro-px--reduced  reduced motion: every channel pinned neutral
 * Driver-built nodes:
 *   ractro-px-stage  ractro-px-bg
 *   ractro-px-deco  --front  --back  --anchor-center  --image  --icon  --text  --color
 */

/* Neutral defaults. An element renders identically before the driver's first
   frame, if the driver never loads, and while it is off screen.

   `--px-persp` defaults to 100000px and NOT to 0: `perspective()` with a zero or
   negative length invalidates the entire transform function list, which would
   silently kill the skew and 3D rotation sharing that declaration. */
.ractro-px {
    --px-tx: 0px;
    --px-ty: 0px;
    --px-sx: 1;
    --px-sy: 1;
    --px-rot: 0deg;
    --px-skx: 0deg;
    --px-sky: 0deg;
    --px-rx: 0deg;
    --px-ry: 0deg;
    --px-tz: 0px;
    --px-persp: 100000px;
    --px-blur: 0px;
    --px-bright: 1;
    --px-sat: 1;
    --px-gray: 0;
    --px-op: 1;
    --px-bg-x: 0px;
    --px-bg-y: 0px;
}

/* D1. The 2D channel NEVER writes `transform`.
   MEASURED: an element with a finished entrance animation (every
   `.ractro-anim-in` uses `animation-fill-mode: both`) ignores a `transform`
   written afterwards — animations outrank the author origin — but honours the
   individual `translate` / `rotate` / `scale` properties. So parallax sums into
   those, alongside the author's static Motion values, and the two compose
   instead of one erasing the other.

   Fallbacks are `0px` / `0deg` / `1`, never bare `0`: `calc(0 + 0px)` mixes a
   number with a length, which is invalid at computed-value time and would take
   the whole declaration down. */
.ractro-px--move.ractro-px--move.ractro-px--move {
    translate:
        calc(var(--ractro-tx, 0px) + var(--px-tx, 0px))
        calc(var(--ractro-ty, 0px) + var(--px-ty, 0px));
}

.ractro-px--rot.ractro-px--rot.ractro-px--rot {
    rotate: calc(var(--ractro-rot, 0deg) + var(--px-rot, 0deg));
}

.ractro-px--scale.ractro-px--scale.ractro-px--scale {
    scale:
        calc(var(--ractro-scale, 1) * var(--px-sx, 1))
        calc(var(--ractro-scale, 1) * var(--px-sy, 1));
}

/* D2. `transform` is only for what has no individual-property equivalent:
   skew, perspective, rotateX/rotateY, translateZ. ONE rule, one class, because
   `transform` is a shorthand and a second declaration would replace the whole
   function list rather than add to it — the same trap FILTER_COMPOSED exists to
   avoid. The author's own `global_skew_x/_y` write the same
   `--ractro-skx/--ractro-sky` this reads, so their static skew survives.

   `.ractro-px--3d` therefore gates SKEW TOO, not only the 3D functions. That
   widens the port spec's condition (§2.1 lists only `persp > 0` and one of
   tilt3d / depth / mouse_tilt) and both derivations must add the skew case, or
   the Skew channel is silently dead. Flat elements get no `transform` at all,
   which is the D5 win.

   Order matters: `perspective()` must come first so it governs every function
   after it. `perspective(100000px)` is the flat default and is why a
   skew-only element can share this declaration harmlessly.

   D4, MEASURED: `transform` is applied INNERMOST (translate -> rotate -> scale
   -> transform), so a 30px transform translate under `scale: 2` renders as
   60px. Everything in THIS declaration — including `translateZ` — is multiplied
   by the author's Scale control and by the parallax scale channel. The driver
   does not compensate; Depth interacting with Scale is documented behaviour.

   `backface-visibility` lives here rather than on `.ractro-px` because it only
   means anything once a rotateX/rotateY can turn a face away, and it is not
   free: it forces a rasterisation path that can shift text rendering. */
.ractro-px--3d.ractro-px--3d.ractro-px--3d {
    transform:
        perspective(var(--px-persp, 100000px))
        skew(calc(var(--ractro-skx, 0deg) + var(--px-skx, 0deg)),
             calc(var(--ractro-sky, 0deg) + var(--px-sky, 0deg)))
        rotateX(var(--px-rx, 0deg))
        rotateY(var(--px-ry, 0deg))
        translateZ(var(--px-tz, 0px));
    transform-style: preserve-3d;
    backface-visibility: hidden;
}

/* D6. Parallax contributes `--px-*` and never writes the `filter` shorthand on
   its own terms — it re-states the SAME chain Ractro composes, reading both
   sides' variables.

   The chain is Ractro_Widget_Manager::FILTER_COMPOSED
   (includes/class-ractro-widget-manager.php:55) with the parallax contribution
   merged into four of its six slots. Same six functions, same order, same
   variable names, same fallbacks — deliberately, because filter functions are
   NOT commutative and a reordered chain would make a parallax element look
   different from a non-parallax one carrying identical static filters. If
   FILTER_COMPOSED ever changes, this declaration changes with it; there is no
   way to share one string between PHP and a static stylesheet, so the constant
   is named here as the thing to grep for.

   Merge arithmetic, chosen per property:
     blur       ADD, one slot.  Two sequential blur() functions compose as
                ~sqrt(a²+b²), which is unpredictable when both sides are set;
                one slot with a summed radius is monotone and matches intuition.
     brightness MULTIPLY. A gain factor whose neutral value is 1.
     saturate   MULTIPLY. Same.
     grayscale  ADD, clamped to 1. An amount, not a gain — two 60%
                desaturations should read as fully grey, not 36%.
     contrast   PASS THROUGH. Not animated by parallax, but it must still be
     hue-rotate restated or re-declaring the shorthand would silently drop the
                author's static value.

   MEASURED, both sides set (--ractro-blur 2px + --px-blur 3px, --ractro-bright
   .5 x --px-bright 2, --ractro-saturate 2 x --px-sat 1.5, --ractro-grayscale .6
   + --px-gray .6), computed filter:

       blur(5px) brightness(1) contrast(1) saturate(3) grayscale(1) hue-rotate(0deg)

   — the clamp holds grayscale at 1 rather than 1.2, which would be invalid. */
.ractro-px--filter.ractro-px--filter.ractro-px--filter {
    filter:
        blur(      calc(var(--ractro-blur, 0px)   + var(--px-blur, 0px)))
        brightness(calc(var(--ractro-bright, 1)   * var(--px-bright, 1)))
        contrast(       var(--ractro-contrast, 1))
        saturate(  calc(var(--ractro-saturate, 1) * var(--px-sat, 1)))
        grayscale( clamp(0, calc(var(--ractro-grayscale, 0) + var(--px-gray, 0)), 1))
        hue-rotate(     var(--ractro-hue, 0deg));
}

/* D6, opacity. Multiply: two independent transparencies compose that way. A
   statically 50%-opaque element fading 0 -> 100% by scroll ends at 50%, which
   is what the author means.

   `--ractro-op` is the variable `global_opacity` must start writing (port spec
   §4b). It does not yet, so on an element that has BOTH a static opacity and
   the Fade channel this currently resolves the author's value to the neutral 1.
   Gating on `--fade` is what keeps that from touching every parallax element. */
.ractro-px--fade.ractro-px--fade.ractro-px--fade {
    opacity: calc(var(--ractro-op, 1) * var(--px-op, 1));
}

/* will-change is scoped to a class the driver adds ONLY while it is actually
   writing frames to this element, and drops on the way off screen.

   D5 again: `will-change: transform` alone hijacks fixed descendants — it is
   not a free hint, it promotes the element and creates a containing block. It
   also costs a compositor layer per element for as long as it is set, which on
   a page of parallax elements is memory spent on elements nobody is scrolling
   past. `--idle` is honoured as well as `--active`'s absence, so a driver that
   adds `--idle` without removing `--active` still releases the layer. */
.ractro-px--active {
    will-change: translate, rotate, scale, filter, opacity;
}

.ractro-px--idle {
    will-change: auto;
}

/* Reduced motion pins the PARALLAX contribution to neutral and leaves every
   static value applying — the element keeps its author-set rotate, scale,
   filters and opacity, it simply stops responding to scroll. The driver also
   skips pushing it to the registry, and still builds the layers, so
   decorations stay visible and statically placed.

   Deliberately a class and not a bare `@media (prefers-reduced-motion: reduce)`
   block: `global_parallax_reduced_motion` is an author control, and a media
   query here would override an author who explicitly opted out. The driver owns
   that decision and reflects it in the class. */
.ractro-px--reduced {
    --px-tx: 0px;
    --px-ty: 0px;
    --px-sx: 1;
    --px-sy: 1;
    --px-rot: 0deg;
    --px-skx: 0deg;
    --px-sky: 0deg;
    --px-rx: 0deg;
    --px-ry: 0deg;
    --px-tz: 0px;
    --px-blur: 0px;
    --px-bright: 1;
    --px-sat: 1;
    --px-gray: 0;
    --px-op: 1;
    --px-bg-x: 0px;
    --px-bg-y: 0px;
    will-change: auto;
}

/* ===========================================================================
   THE LAYER STAGE
   ===========================================================================
   Only elements carrying `ractro-px--host` have one. The engine un-flattens an
   atomic-root widget when `Ractro_Parallax::needs_host()` is true, so the host
   is always a real `<div>` with the widget's root as a child — a background
   layer or a decoration layer can never be asked to live inside an `<img>`.

   Nothing in this feature uses `{{WRAPPER}}::before` (reserved for the
   universal background overlay) or `{{WRAPPER}}::after` (on the 84 atomic-root
   widgets the wrapper IS the widget's own root, and widget stylesheets use
   `::after` on their roots freely). The one pseudo-element here belongs to
   `.ractro-px-bg`, a node the driver created, which no widget stylesheet can
   target.

   Both host declarations use `:where()`, i.e. specificity (0,0,0), on purpose.
   They are floors, not overrides: an author `display: flex` from the Advanced
   tab and `position: sticky` from `.ractro-sticky.ractro-sticky--top` (0,2,0)
   must both keep winning. An initial value loses to any author declaration
   however weak, so these still apply whenever nothing else sets them — and
   `sticky` and `absolute` establish a containing block just as `relative` does,
   so the stage's `inset: 0` is correct under all of them. */
:where(.ractro-px--host) {
    position: relative;
    /* flow-root, not block: the host may wrap an inline or replaced root
       (`<span>`, `<a>`, `<img>`) once the element is un-flattened, and it must
       establish a block formatting context rather than collapse. */
    display: flow-root;
}

/* The author's "Clip layers to element" switch. Doubled to (0,2,0) so it beats
   the compiled `global_overflow` at (0,1,0) — the author asked for this
   specific element to clip, and that is the more specific instruction.

   Clipping is NEVER applied to the host by default. The background layer is
   deliberately over-sized so its travel cannot expose an edge, but clipping the
   element to hide that would also cut off sticky children, dropdowns and
   tooltips. The background clips itself instead (see `.ractro-px-bg`), so the
   default is that decorations spill and nothing else is affected. */
.ractro-px--clip.ractro-px--clip {
    overflow: hidden;
}

/* One stage per host, inserted by the driver as the host's first child and
   owned entirely by it. `overflow: visible` is the default so decorations may
   spill outside the element; the clip switch above is what takes that away. */
.ractro-px-stage {
    position: absolute;
    inset: 0;
    overflow: visible;
    pointer-events: none;
    /* auto, NOT 0. A positioned box with a NUMERIC z-index opens a stacking
       context, which would trap .ractro-px-deco--front (z-index 3) inside the
       stage and render an "in front of content" layer BEHIND the content that
       line 373 lifts to 1. auto keeps the layers in the host's context, where
       the 3-vs-1 comparison is the one the author asked for. */
    z-index: auto;
}

/* Stacking. The stage is the FIRST child, so with no z-index at all its layers
   would still paint above ordinary in-flow content: a positioned element with
   `z-index: auto | 0` paints after the block and inline content of its parent.
   Front layers get `z-index: 3` (above content). Back layers get `z-index: 0`
   and the content is lifted to `1` to sit between them.

   A negative z-index on the back layer would avoid touching the content — and
   would also paint it behind the HOST'S OWN BACKGROUND, since the host
   establishes no stacking context. That is not what "behind content" means.

   The content lift is split in two on purpose. `z-index` is normal specificity;
   `position: relative` is wrapped in `:where()` so it is a floor an author's
   own Position control always beats, exactly as with the host above. */
.ractro-px-deco--front { z-index: 3; }
.ractro-px-deco--back  { z-index: 0; }

.ractro-px--host > :not(.ractro-px-stage) { z-index: 1; }
:where(.ractro-px--host > :not(.ractro-px-stage)) { position: relative; }

/* The background layer.

   It clips ITSELF: the node is flush with the stage and the oversized, moving
   image lives on its `::before`. That is what lets the clip switch stay purely
   about decorations — a background whose travel is `--px-bg-extra` px beyond
   each edge can never expose an edge, and never needs the element clipped.

   `--px-bg-image`, `-size`, `-position`, `-zoom`, `-overlay` and `-blend` are
   hand-compiled into the page stylesheet on `.ractro-element-<id>.ractro-px--bg`
   (the image control's value is an object, which `{{VALUE}}` substitution
   cannot express — the `global_bg_overlay` precedent). Custom properties
   inherit, so they reach the pseudo-elements without being re-declared.

   Movement is `translate`, never a `background-position` change: repainting a
   background position invalidates the whole layer every frame, whereas
   `translate` on the individual property stays on the compositor and cannot be
   clobbered by anything on `transform`. */
.ractro-px-bg {
    position: absolute;
    inset: 0;
    overflow: hidden;
}

.ractro-px-bg::before {
    content: "";
    position: absolute;
    left:   calc(var(--px-bg-extra-x, 30px) * -1);
    right:  calc(var(--px-bg-extra-x, 30px) * -1);
    top:    calc(var(--px-bg-extra, 60px) * -1);
    bottom: calc(var(--px-bg-extra, 60px) * -1);
    background-image: var(--px-bg-image, none);
    background-size: var(--px-bg-size, cover);
    background-position: var(--px-bg-position, center center);
    background-repeat: no-repeat;
    translate: var(--px-bg-x, 0px) var(--px-bg-y, 0px);
    scale: var(--px-bg-zoom, 1);
}

/* The overlay tint. A pseudo-element of a driver-built node, so nothing that
   CLAUDE.md reserves is touched. */
.ractro-px-bg::after {
    content: "";
    position: absolute;
    inset: 0;
    background: var(--px-bg-overlay, transparent);
    mix-blend-mode: var(--px-bg-blend, normal);
}

.ractro-px--active .ractro-px-bg::before {
    will-change: translate;
}

/* Decoration layers. Everything static — width, height, position, base rotate,
   opacity, blend, blur, radius, colour and all ten typography fields — is
   applied by the driver as an inline style at build time, because a repeater's
   fields never reach the CSS compiler (it walks top-level controls only). Only
   the four per-frame variables below are written each frame. */
.ractro-px-deco {
    position: absolute;
    translate:
        calc(var(--pxl-ax, 0%) + var(--pxl-tx, 0px))
        calc(var(--pxl-ay, 0%) + var(--pxl-ty, 0px));
    rotate: var(--pxl-rot, 0deg);
    scale: var(--pxl-sc, 1);
    pointer-events: none;
    line-height: 1;
}

.ractro-px--active .ractro-px-deco {
    will-change: translate, rotate, scale;
}

/* Anchor modes, from `px_layer_anchor`.

   `topleft` is the initial state: the layer's top-left corner sits on the
   Position X/Y point, which the driver writes as inline `left`/`top`
   percentages. `center` shifts the layer back by half its own size.

   That shift is expressed as a PERCENTAGE INSIDE `translate`, not as
   `transform: translate(-50%, -50%)`. D4: `transform` is applied innermost and
   is multiplied by `scale`, so a transform-based anchor would drift off the
   anchor point as the layer scales — a 2x layer would be offset by a full
   width. `translate` is applied before `scale` in the composition order and is
   therefore immune, and percentages there resolve against the layer's own
   border box, which is exactly what an anchor needs. It also keeps `transform`
   entirely unused on decoration nodes.

   MEASURED on a 200x80 layer with `--pxl-tx: 10px` and `--pxl-sc: 2`:
   translate `calc(-50% + 10px) -50%`, scale `2`, transform `none` — the anchor
   offset stays half the layer's own size at any scale. */
.ractro-px-deco--anchor-center {
    --pxl-ax: -50%;
    --pxl-ay: -50%;
}

/* Layer types. Structural only — colour, size and typography are inline. */
.ractro-px-deco--image img,
.ractro-px-deco--icon svg,
.ractro-px-deco--icon img {
    display: block;
    width: 100%;
    height: auto;
}

.ractro-px-deco--color {
    /* A shape is its own box; the driver sets background-color, width, height
       and border-radius inline from the shape and radius fields. */
    box-sizing: border-box;
}

.ractro-px-deco--text {
    white-space: nowrap;
}

/* Curved text is an `<svg>` the driver builds with a padded viewBox. `overflow:
   visible` keeps a stroke or a text shadow from being cut at the viewBox edge;
   the padding in the viewBox handles the glyphs themselves. */
.ractro-px-deco--text svg {
    display: block;
    overflow: visible;
}
