/**
 * LNX Motion — opt-in entrance animation for profile pages.
 *
 * ONE file, loaded by BOTH the published page (templates/base.php) and the
 * editor live preview (frontend/src/main.tsx imports this exact path). Never
 * fork it: base.css and frontend/src/styles/lnx-themes.css are already two
 * copies of the same rules, and that duplication is a recurring source of
 * "renders on the public page, dead in the preview" bugs.
 *
 * Contract
 *  - The scope element carries data-motion="<preset>" (public: <html>,
 *    preview: .lnx-page). The attribute is OMITTED when the preset is "none",
 *    so every rule below can key off a bare [data-motion].
 *  - Intensity/speed arrive as unitless factors --lnx-motion-i / --lnx-motion-s
 *    (public: ProfileRenderer::generateCss; preview: inline style). Presets set
 *    the base timing, the factors scale it. Bounds are baked into the presets:
 *    no combination can produce something ugly.
 *  - Content is VISIBLE by default. Animations only add a hidden->visible
 *    transition via animation-fill-mode: backwards, so if this file fails to
 *    load, or the browser ignores it, nothing disappears.
 *  - Only opacity / transform / filter are animated — never layout properties.
 *  - prefers-reduced-motion is a hard gate at the bottom of this file. It is
 *    not user-overridable.
 *
 * Design notes
 *  - The page arrives as a SEQUENCE, not a flash: portrait, then name, then
 *    tagline, then bio, a beat, then each link in turn. The stagger is ~100ms
 *    because anything under ~70ms reads as "everything at once".
 *  - Elements animate by ROLE, not uniformly. Images scale in (they have mass);
 *    text rises; the further down the page an element sits, the less it travels.
 *    That difference is what makes the sequence feel choreographed.
 *  - Easing is expo-out (fast departure, long soft settle) rather than a generic
 *    ease — it is most of the perceived quality.
 */

/* ==================== */
/* TOKENS + PRESETS     */
/* ==================== */

:root {
    /* Preset bases. Roles derive their own values from these below. */
    --lnx-motion-y-base: 16px;      /* entrance travel */
    --lnx-motion-scale-base: 1;     /* entrance start scale (1 = no scaling) */
    --lnx-motion-blur-base: 0px;    /* entrance start blur */
    --lnx-motion-dur: 700ms;
    --lnx-motion-stagger: 100ms;
    --lnx-motion-ease: cubic-bezier(0.16, 1, 0.3, 1);
    /* Which keyframe set runs. Carried as a variable so the (long) list of
       animated targets is written ONCE — a per-preset copy of that list is how
       the circles-layout bug below stayed invisible. Declared HERE, above the
       presets: [data-motion] and [data-motion="<preset>"] have equal specificity
       and both match <html>, so a default set after them would always win. */
    --lnx-motion-name: lnx-enter;

    /*
     * Scaling factors, set per profile from the ONE Strength control. Kept
     * unitless so they compose in calc().
     *   subtle .6/.85 | balanced 1/1 | bold 1.5/1.15
     * Strength moves travel and timing together: as two separate three-way
     * controls, one step of travel-only Strength was imperceptible on fade and
     * blur (~1.5% of the frame, below the threshold at which the presets
     * themselves read as different).
     */
    --lnx-motion-i: 1;              /* travel / scale / blur */
    --lnx-motion-s: 1;              /* durations and stagger */
}

/*
 * Effective stagger, floored. Below ~85ms a staggered sequence measures as
 * staggered and reads as "everything at once", so Strength is allowed to
 * stretch the beat but never to collapse it.
 */
[data-motion] {
    --lnx-motion-beat: max(85ms, calc(var(--lnx-motion-stagger) * var(--lnx-motion-s)));
}

/*
 * The presets must differ in KIND, not in magnitude.
 *
 * The first cut separated them by numbers alone — 7px / 16px / 14px of travel,
 * all on a fast-out curve — and they measured as the same animation: a frame
 * diff between fade, rise and spring peaked at ~1% of full scale, because an
 * expo-out curve spends 85% of its travel in the first 200ms and what is left
 * for the eye is an opacity ramp that every preset shared. So:
 *
 *   fade    nothing moves; a genuinely slow dissolve on a curve that does NOT
 *           front-load (0.45,0,0.3,1 is only 25% done at 30% of its duration).
 *   rise    a slide you can actually follow — travel large enough that even the
 *           tail of an expo-out curve is visible.
 *   spring  a real overshoot, written as keyframes. A cubic-bezier cannot bounce
 *           more than ~4% of the delta, which at 14px of travel was 0.57px — a
 *           sub-pixel spring, i.e. no spring at all.
 *   blur    changes a different property class entirely, which is why it was the
 *           one preset that already read as distinct. Left alone.
 */

/* Softest — a true cross-dissolve. Travel is a token 5px so Strength still has
   something to scale; the character comes from the curve and the duration. */
[data-motion="fade"] {
    --lnx-motion-y-base: 5px;
    --lnx-motion-dur: 900ms;
    --lnx-motion-stagger: 95ms;
    --lnx-motion-ease: cubic-bezier(0.45, 0, 0.3, 1);
}

[data-motion="rise"] {
    --lnx-motion-y-base: 40px;
    --lnx-motion-dur: 720ms;
    --lnx-motion-stagger: 100ms;
    --lnx-motion-ease: cubic-bezier(0.16, 1, 0.3, 1);
}

[data-motion="spring"] {
    --lnx-motion-y-base: 30px;
    --lnx-motion-scale-base: 0.94;
    --lnx-motion-dur: 820ms;
    --lnx-motion-stagger: 108ms;
    --lnx-motion-name: lnx-enter-spring;
    /* The bounce lives in the keyframes; this curve only drives opacity. */
    --lnx-motion-ease: cubic-bezier(0.3, 0.8, 0.4, 1);
}

[data-motion="blur"] {
    --lnx-motion-y-base: 12px;
    --lnx-motion-blur-base: 8px;
    --lnx-motion-dur: 780ms;
    --lnx-motion-stagger: 105ms;
    --lnx-motion-name: lnx-enter-blur;
    --lnx-motion-ease: cubic-bezier(0.16, 1, 0.3, 1);
}

/* Effective per-element values. Roles override these; everything else inherits
   the preset base. */
[data-motion] {
    --lnx-motion-y: var(--lnx-motion-y-base);
    --lnx-motion-scale: var(--lnx-motion-scale-base);
    --lnx-motion-blur: var(--lnx-motion-blur-base);
}

/* ==================== */
/* KEYFRAMES            */
/* ==================== */

/*
 * Only a `from` keyframe: the implicit 100% keyframe resolves to the element's
 * own computed style, so the animation never fights the theme (or a :hover
 * transform once it has finished).
 *
 * The strength math lives INSIDE the keyframe on purpose — custom properties
 * resolve against the animating element, which is what makes the per-role and
 * per-block (--lnx-motion-em) overrides work.
 */
@keyframes lnx-enter {
    from {
        opacity: 0;
        transform:
            translate3d(0, calc(var(--lnx-motion-y) * var(--lnx-motion-i) * var(--lnx-motion-em, 1)), 0)
            scale(calc(1 - (1 - var(--lnx-motion-scale)) * var(--lnx-motion-i) * var(--lnx-motion-em, 1)));
    }
}

/*
 * A cubic-bezier can overshoot by at most a few percent of the delta, so the
 * bounce has to be written as explicit keyframes: out past the resting position,
 * back a little short of it, then settle. Only `transform` appears in the
 * intermediate keyframes — opacity keeps its two stops (0% and the implicit
 * 100%) so it stays monotonic and never dips back down mid-bounce, which also
 * keeps elements with a resting opacity below 1 (.lnx-bio) from flashing.
 */
@keyframes lnx-enter-spring {
    from {
        opacity: 0;
        transform:
            translate3d(0, calc(var(--lnx-motion-y) * var(--lnx-motion-i) * var(--lnx-motion-em, 1)), 0)
            scale(calc(1 - (1 - var(--lnx-motion-scale)) * var(--lnx-motion-i) * var(--lnx-motion-em, 1)));
    }

    /* Past the resting position — this is the whole point of the preset.
       The return curve leaves the peak promptly (rather than a symmetric
       ease-in-out) because the launch curve arrives early: with both eased in,
       the element visibly hangs at full overshoot for ~120ms and reads floaty. */
    46% {
        transform:
            translate3d(0, calc(var(--lnx-motion-y) * var(--lnx-motion-i) * var(--lnx-motion-em, 1) * -0.28), 0)
            scale(calc(1 + (1 - var(--lnx-motion-scale)) * var(--lnx-motion-i) * var(--lnx-motion-em, 1) * 0.4));
        animation-timing-function: cubic-bezier(0.25, 0, 0.3, 1);
    }

    /* Second, much smaller swing back. */
    72% {
        transform:
            translate3d(0, calc(var(--lnx-motion-y) * var(--lnx-motion-i) * var(--lnx-motion-em, 1) * 0.09), 0)
            scale(calc(1 - (1 - var(--lnx-motion-scale)) * var(--lnx-motion-i) * var(--lnx-motion-em, 1) * 0.12));
        animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
    }
}

/* Separate keyframe so non-blur presets never pay for a filter (a filter forces
   GPU rasterization and a new containing block for the whole animation). */
@keyframes lnx-enter-blur {
    from {
        opacity: 0;
        transform:
            translate3d(0, calc(var(--lnx-motion-y) * var(--lnx-motion-i) * var(--lnx-motion-em, 1)), 0)
            scale(calc(1 - (1 - var(--lnx-motion-scale)) * var(--lnx-motion-i) * var(--lnx-motion-em, 1)));
        filter: blur(calc(var(--lnx-motion-blur) * var(--lnx-motion-i)));
    }
}

/* ==================== */
/* ANIMATED TARGETS     */
/* ==================== */

/*
 * The animated set, identical in the published page and the preview:
 *   media   .lnx-photo / .lnx-hero / .lnx-side-photo
 *   text    .lnx-title, .lnx-subtitle (only where they are NOT inside an
 *           animating .lnx-hero — boxy overlays them on the hero image, and
 *           animating both would double-transform), .lnx-bio
 *   blocks  every direct child of .lnx-content. In the published page those are
 *           .lnx-block wrappers; in the preview the anchors are direct children.
 *           `> *` covers both — do not narrow it to .lnx-block.
 *   icons   individual social icons, so a row of them arrives one by one rather
 *           than as a single lump. Their display:contents wrapper has no box to
 *           animate anyway.
 *   circles the SAME display:contents trap, and it silently killed the whole
 *           feature on circles-layout pages: base.css collapses
 *           `.lnx-layout-circles .lnx-block`, so the wrapper this file animates
 *           generates no box and neither opacity nor transform renders. The
 *           preview puts the anchors directly under .lnx-content and so animated
 *           correctly — published pages did not animate their links at all.
 *           Target the button itself; --lnx-i, --lnx-motion-em and
 *           --lnx-motion-late all inherit from the wrapper, so ordering and the
 *           per-block overrides keep working unchanged.
 */
[data-motion] .lnx-photo,
[data-motion] .lnx-hero,
[data-motion] .lnx-side-photo,
[data-motion] .lnx-header .lnx-title,
[data-motion] .lnx-header .lnx-subtitle,
[data-motion] .lnx-body > .lnx-title,
[data-motion] .lnx-body > .lnx-subtitle,
[data-motion] .lnx-bio,
[data-motion] .lnx-content > *:not(.lnx-block--social_icons),
[data-motion] .lnx-content > .lnx-block > .lnx-circle-link,
[data-motion] .lnx-block-social__link {
    animation-name: var(--lnx-motion-name);
    animation-duration: calc(var(--lnx-motion-dur-role, var(--lnx-motion-dur)) * var(--lnx-motion-s));
    animation-timing-function: var(--lnx-motion-ease);
    /* backwards, NOT both: hide during the delay, then hand the element back to
       normal styling so hover/active transforms behave. */
    animation-fill-mode: backwards;
    animation-delay: calc(
        min(var(--lnx-i, 0), 13) * var(--lnx-motion-beat)
        + var(--lnx-j, 0) * var(--lnx-motion-beat) * 0.45
        + var(--lnx-motion-late, 0ms) * var(--lnx-motion-s)
    );
}

/* ==================== */
/* ROLE CHOREOGRAPHY    */
/* ==================== */

/* Images have mass: they barely travel, they settle out of a slight scale, and
   they take a little longer than the text that follows them. */
[data-motion] .lnx-photo,
[data-motion] .lnx-hero,
[data-motion] .lnx-side-photo {
    --lnx-motion-y: calc(var(--lnx-motion-y-base) * 0.45);
    --lnx-motion-scale: calc(var(--lnx-motion-scale-base) - 0.03);
    --lnx-motion-dur-role: calc(var(--lnx-motion-dur) * 1.15);
}

/* Supporting text travels less than the name it sits under. */
[data-motion] .lnx-header .lnx-subtitle,
[data-motion] .lnx-body > .lnx-subtitle {
    --lnx-motion-y: calc(var(--lnx-motion-y-base) * 0.8);
}

[data-motion] .lnx-bio {
    --lnx-motion-y: calc(var(--lnx-motion-y-base) * 0.65);
}

/* Buttons carry a whisper of scale so a stack of them doesn't read as one slab
   sliding up. */
[data-motion] .lnx-content > * {
    --lnx-motion-scale: calc(var(--lnx-motion-scale-base) - 0.012);
}

[data-motion] .lnx-block-social__link {
    --lnx-motion-y: calc(var(--lnx-motion-y-base) * 0.6);
    --lnx-motion-scale: calc(var(--lnx-motion-scale-base) - 0.04);
}

/* ==================== */
/* SEQUENCE ORDER       */
/* ==================== */

/*
 * --lnx-i is the stagger index; --lnx-j is a sub-index for items inside a block
 * (they follow their parent more tightly, at 0.45x the stagger).
 *
 * Assigned structurally so NO template or React component has to emit a
 * per-element index: the published page and the preview render the same class
 * structure, so the same rules order both.
 *
 * Note the gap between the header (0-3) and the first link (5): that missing
 * beat is what separates "who this is" from "what they want you to do".
 * The index is capped at 13 so a 40-link page still finishes arriving.
 */
[data-motion] .lnx-photo,
[data-motion] .lnx-hero,
[data-motion] .lnx-side-photo { --lnx-i: 0; }

[data-motion] .lnx-header .lnx-title,
[data-motion] .lnx-body > .lnx-title { --lnx-i: 1; }

[data-motion] .lnx-header .lnx-subtitle,
[data-motion] .lnx-body > .lnx-subtitle { --lnx-i: 2; }

[data-motion] .lnx-bio { --lnx-i: 3; }

[data-motion] .lnx-content > *:nth-child(1) { --lnx-i: 5; }
[data-motion] .lnx-content > *:nth-child(2) { --lnx-i: 6; }
[data-motion] .lnx-content > *:nth-child(3) { --lnx-i: 7; }
[data-motion] .lnx-content > *:nth-child(4) { --lnx-i: 8; }
[data-motion] .lnx-content > *:nth-child(5) { --lnx-i: 9; }
[data-motion] .lnx-content > *:nth-child(6) { --lnx-i: 10; }
[data-motion] .lnx-content > *:nth-child(7) { --lnx-i: 11; }
[data-motion] .lnx-content > *:nth-child(8) { --lnx-i: 12; }
[data-motion] .lnx-content > *:nth-child(n+9) { --lnx-i: 13; }

/* Social icons follow their own block (--lnx-i inherits) one after another. */
[data-motion] .lnx-block-social__link:nth-child(1) { --lnx-j: 0; }
[data-motion] .lnx-block-social__link:nth-child(2) { --lnx-j: 1; }
[data-motion] .lnx-block-social__link:nth-child(3) { --lnx-j: 2; }
[data-motion] .lnx-block-social__link:nth-child(4) { --lnx-j: 3; }
[data-motion] .lnx-block-social__link:nth-child(5) { --lnx-j: 4; }
[data-motion] .lnx-block-social__link:nth-child(6) { --lnx-j: 5; }
[data-motion] .lnx-block-social__link:nth-child(n+7) { --lnx-j: 6; }

/* The lnx.net footer is deliberately NOT animated: with no way to know the block
   count in CSS, any fixed index either leaves a dead pause after the last link on
   a short page or arrives before blocks that sit above it on a long one. */

/* ==================== */
/* PER-BLOCK OVERRIDES  */
/* ==================== */

/* Set from the block's own data (data-motion-block). Deliberately three
   options — the palette stays curated.
   The .lnx-content > chain matches the specificity of the target rules above;
   these come later in the file, so they win the tie. */
[data-motion] .lnx-content > *[data-motion-block="none"],
[data-motion] .lnx-content > *[data-motion-block="none"] > *,
/* Matches the specificity of the circles rule above so "no animation" still
   wins there; it comes later in the file, so it takes the tie. */
[data-motion] .lnx-content > .lnx-block[data-motion-block="none"] > .lnx-circle-link {
    animation-name: none;
}

[data-motion] .lnx-content > *[data-motion-block="emphasize"] {
    --lnx-motion-em: 1.6;
}

/* Arrives after everything else, regardless of its position in the list. */
[data-motion] .lnx-content > *[data-motion-block="late"] {
    --lnx-motion-late: 520ms;
}

/* ==================== */
/* SCROLL REVEAL        */
/* ==================== */

/*
 * Blocks past the 8th are almost always below the fold, where a load-time
 * entrance is wasted — they animate as they scroll into view instead. Pure CSS,
 * no IntersectionObserver, no JS at all.
 *
 * If view() is unsupported the @supports block is skipped and those blocks keep
 * the load-time entrance. If the page does not scroll the timeline is inactive
 * and the element simply renders visible — both fallbacks fail safe.
 */
@supports (animation-timeline: view()) {
    [data-motion] .lnx-content > *:nth-child(n+9):not(.lnx-block--social_icons) {
        animation-timeline: view();
        animation-range: entry 0% entry 90%;
        /* Progress on a view timeline comes from animation-range; a time delay
           would only desynchronise it from the scroll position. */
        animation-delay: 0s;
    }
}

/* ==================== */
/* BACKGROUND MOTION    */
/* ==================== */

/*
 * A slow drift of the profile's OWN background gradient. Independent of the
 * entrance preset — it has its own attribute and can be on with motion off.
 *
 * Offered only when the background is a gradient, because that is the only case
 * where the page holds two distinct colours, and motion is perceived at contrast
 * edges rather than in smooth low-contrast fields. That is exactly why the
 * earlier "ambient" wash was removed: two blobs of the SAME hue drifting ~2px/s
 * with a slow global opacity swing, which is the one channel human vision is
 * best at cancelling. It was not too faint — it was aimed at the wrong thing.
 * The keyframes below move a real colour boundary at roughly 13-26px/s.
 *
 * ONE oversized composited layer, transform only. Never animate the gradient
 * stops or background-position: that repaints the largest box on the page every
 * frame, on the main thread, and it is the difference between a free animation
 * and a hot phone.
 *
 * The layer paints the SAME value the static background already carries, so a
 * browser that ignores these rules still renders the gradient. The animation can
 * only ever be additive.
 */
/*
 * ROTATION, not translation. Measured both: sliding a two-stop gradient moves
 * every corner of the screen the same direction at once — a uniform luminance
 * drift, which is precisely the signal human vision adapts away (the corners
 * tracked within 3/255 of each other over 3.5s). Rotating it moves opposite
 * corners in OPPOSITE directions, because the colour at a point shifts with its
 * distance from the centre. That differential is what the eye actually reads.
 *
 * One continuous turn rather than an oscillation: a rotation that reverses has
 * to stall at both ends, and it would have to be several times faster to carry
 * the same signal. A full turn at ~3.3 deg/s loops seamlessly with no reversal
 * artefact and needs no easing. Over ~55s the light has worked its way to the
 * opposite side of the page — slow enough to read as the light moving, not as
 * the design changing.
 */
@keyframes lnx-bg-drift {
    to { transform: rotate(1turn); }
}

/*
 * A SQUARE of 142vmax, centred. Under rotation the layer must cover the whole
 * viewport at every angle, so it has to be at least as wide as the viewport
 * diagonal — and sqrt(vw^2 + vh^2) <= sqrt(2) * vmax = 141.5vmax for ANY aspect
 * ratio. Sizing per-axis (145vw x 145vh) is the tempting version and it fails:
 * on a tall phone the width needed under rotation comes mostly from the HEIGHT.
 * Centred with margins rather than a translate, so the keyframe owns `transform`.
 */
html[data-bg-motion] body::before,
.lnx-page[data-bg-motion]::before {
    content: '';
    position: fixed;
    top: 50%;
    left: 50%;
    width: 142vmax;
    height: 142vmax;
    margin: -71vmax 0 0 -71vmax;
    z-index: 0;
    pointer-events: none;
    background: var(--lnx-bg-page, var(--lnx-color-bg-page));
    animation: lnx-bg-drift 110s linear infinite;
    will-change: transform;
}

/* Preview: the pane is not the viewport, so the layer is contained instead. */
.lnx-page[data-bg-motion] {
    position: relative;
    overflow: hidden;
}

.lnx-page[data-bg-motion]::before {
    position: absolute;
}

/*
 * Keep the card and the footer above the drift.
 *
 * The layer is a POSITIONED ::before, and positioned boxes paint above
 * non-positioned block descendants no matter what order they sit in the DOM —
 * so the content has to be raised explicitly or the wash covers the card
 * outright. This needs a rule per context: on the published page the attribute
 * is on <html> and .lnx-page is a descendant, but in the preview the attribute
 * is ON .lnx-page, so a `[data-bg-motion] .lnx-page` descendant selector matches
 * nothing and .lnx-profile is left underneath. Scoping only to `html[...]` is
 * exactly the bug that shipped: correct published page, card erased in the
 * editor.
 */
[data-bg-motion] .lnx-page,
[data-bg-motion] .lnx-footer,
.lnx-page[data-bg-motion] > * {
    position: relative;
    z-index: 1;
}

/* ==================== */
/* HOVER / PRESS        */
/* ==================== */

/* Retunes the base.css hover (scale only) into something with a direction.
   Specificity beats the base rule regardless of stylesheet order. */
[data-motion] .lnx-block-link,
[data-motion] .lnx-circle-link {
    transition:
        transform calc(260ms * var(--lnx-motion-s)) var(--lnx-motion-ease),
        box-shadow calc(260ms * var(--lnx-motion-s)) var(--lnx-motion-ease);
}

[data-motion] .lnx-block-link:hover,
[data-motion] .lnx-circle-link:hover {
    transform: translate3d(0, calc(-2px * var(--lnx-motion-i)), 0) scale(calc(1 + 0.02 * var(--lnx-motion-i)));
}

[data-motion] .lnx-block-link:active,
[data-motion] .lnx-circle-link:active {
    transform: translate3d(0, 0, 0) scale(0.99);
    transition-duration: 90ms;
}

/* ==================== */
/* REDUCED MOTION       */
/* ==================== */

/*
 * Hard gate. A visitor who has asked their OS for less motion gets a completely
 * still page — the profile owner's strength setting cannot override this.
 */
@media (prefers-reduced-motion: reduce) {
    [data-motion] .lnx-photo,
    [data-motion] .lnx-hero,
    [data-motion] .lnx-side-photo,
    [data-motion] .lnx-header .lnx-title,
    [data-motion] .lnx-header .lnx-subtitle,
    [data-motion] .lnx-body > .lnx-title,
    [data-motion] .lnx-body > .lnx-subtitle,
    [data-motion] .lnx-bio,
    [data-motion] .lnx-content > *,
    [data-motion] .lnx-content > .lnx-block > .lnx-circle-link,
    [data-motion] .lnx-block-social__link {
        animation: none !important;
    }

    [data-motion] .lnx-block-link:hover,
    [data-motion] .lnx-circle-link:hover,
    [data-motion] .lnx-block-link:active,
    [data-motion] .lnx-circle-link:active {
        transform: none;
    }

    /* Removed rather than frozen: a stopped drift layer would still show the
       gradient cropped to the middle 69% of itself, which is not the page the
       owner designed. Dropping it hands the view back to the static background
       underneath — a completely still page, exactly as designed. */
    html[data-bg-motion] body::before,
    .lnx-page[data-bg-motion]::before {
        display: none;
    }
}
