/* ---------------------------------------------------------------------------
   UlricCMS shared motion. Lives in core/ because the fork ritual rsyncs core/
   and skips themes/, so anything left in a theme cannot be inherited: that is
   how six forks ended up with six hand-copied observers and one fork
   with none at all. The same was true inside this repo: ulric and fold each
   carried their own IntersectionObserver in layout.php and scrub carried a
   third in scrub.js. This file and motion.js are now the only copy.

   The one rule this file exists to enforce: MOTION NEVER GATES CONTENT.
   Every hidden state below is scoped to html.fx, and the boot snippet drops
   that class if motion.js has not reported in. So a blocked script, a 404, a
   throw, a browser with no IntersectionObserver, or a reader with JS off all
   land in the same place: the page, fully visible, unanimated.

   Why html.fx and not html.js: html.js means "scripts are running", and the
   themes' navigation depends on it (a menu that hides itself must know a
   button can open it again). Motion needs a separate word, because motion
   surrenders in cases where scripts are perfectly healthy: reduced motion,
   ?nofx=1, no IntersectionObserver. Surrendering on html.js would have dumped
   the whole navigation inline for every reduced-motion reader.

   Vocabulary
     .fade            · reveals on scroll (adds .in)
     .reveal          · the same thing, the name the themes already use
     .stagger         · parent; its .fade children come in on a 90ms ladder
     .img-ease        · image settle, added BY SCRIPT only (never authored)
     [data-settle]    · asks for the settle on an element script would skip
     [data-nosettle]  · asks script to leave this image alone
     [data-flip]      · headline whose words flip up on load (JS wraps them)
     .no-fx           · ?nofx=1 QA mode: everything visible, nothing moving
     [data-motion-skip] · subtree the reveal engine must not touch (carousels)

   Browser floor: Chrome 79 and equivalents (the studio gets read from a Tesla
   car browser). Nothing here needs a feature newer than that, and the two
   that are newer, clip-path on a replaced element and the independent scale
   property, sit behind @supports with the plain opacity fade underneath.
--------------------------------------------------------------------------- */

/* ---------- scroll reveal ---------- */
html.fx .fade,
html.fx .reveal {
  opacity: 0;
  transform: translateY(22px);
  transition: opacity .7s cubic-bezier(.2, .6, .2, 1), transform .7s cubic-bezier(.2, .6, .2, 1);
}
html.fx .fade.in,
html.fx .reveal.in { opacity: 1; transform: none; }

/* Carousel clones are built AFTER the observer has run, so a cloned .fade card
   carries the hidden state with no observer watching it and never comes back.
   One fork stranded a page of listings this way. Anything inside a marked subtree
   (or a Splide track) opts out of hiding entirely. */
html.fx [data-motion-skip] .fade,
html.fx .splide__list .fade,
html.fx .splide__slide .fade { opacity: 1; transform: none; transition: none; }

/* ---------- image settle ---------- */
/* Armed by script only: with no JS the class is never added and the photo is
   simply there. The settle is a clip and a hair of scale, never a translate,
   for one concrete reason: transform on an image would collide with the theme
   sheets that already animate images on hover, and transform:none would win
   the tie and kill the hover. clip-path and the independent scale property
   compose with transform instead of replacing it, and script strips both
   classes once the settle is done, so nothing lingers on the element. */
html.fx .img-ease {
  opacity: 0;
  transition: opacity .85s cubic-bezier(.2, .6, .2, 1),
              clip-path .85s cubic-bezier(.2, .6, .2, 1),
              scale .85s cubic-bezier(.2, .6, .2, 1);
}
html.fx .img-ease.img-in { opacity: 1; }

/* Chrome 55+ / Safari 9.1+. Older engines get the opacity fade alone. */
@supports (clip-path: inset(1px)) {
  html.fx .img-ease { clip-path: inset(0 0 13% 0); }
  html.fx .img-ease.img-in { clip-path: inset(0 0 0 0); }
}
/* Chrome 104+ / Safari 14.1+. Below that there is simply no scale, which is
   the point: the settle still reads, it just settles flat. */
@supports (scale: 1) {
  html.fx .img-ease { scale: 1.022; }
  html.fx .img-ease.img-in { scale: 1; }
}

/* ---------- headline word flip ---------- */
/* Same discipline: .flip is added by script after it has wrapped the words, so
   the hidden .fw state can never apply to an unwrapped headline. */
.flip { perspective: 820px; }
.fw { display: inline-block; }
.flip .fw {
  opacity: 0;
  transform: rotateX(86deg);
  transform-origin: 50% 88%;
  animation: ulr-wordflip .62s cubic-bezier(.2, .65, .25, 1) forwards;
  animation-delay: calc(.1s + var(--i, 0) * .07s);
}
.flip .fw-em {
  opacity: 0;
  animation: ulr-emin .7s ease forwards;
  animation-delay: var(--emd, 1s);
}
@keyframes ulr-wordflip { to { opacity: 1; transform: none; } }
@keyframes ulr-emin { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }

/* rotateX needs 3D. A browser without transform support ignores the rule and
   the animation, and the opacity keyframe still finishes the job; this states
   the fallback rather than leaving it to luck. */
@supports not (transform: rotateX(1deg)) {
  .flip .fw { opacity: 1; animation: none; }
}

/* ---------- autoplay stage pause/play ---------- */
/* motion.js appends one to every [data-autoplay] stage it runs, so it only
   exists where something is actually moving. It sits bottom left, on the line
   of the step rail and aligned to the content column (--max/--pad when the
   theme sets them); a theme indents its rail to make room. The top corner was
   tried first and collides with wrapped titles on phones. It takes the
   stage's own text colour, so it reads on light and dark stages alike. */
:where([data-autoplay]) { position: relative; }
.stage-pp {
  position: absolute; z-index: 3;
  bottom: 14px; left: max(var(--pad, 20px), calc((100% - var(--max, 100%)) / 2 + var(--pad, 20px)));
  width: 44px; height: 44px; padding: 0; margin: 0;
  display: grid; place-items: center;
  appearance: none; -webkit-appearance: none;
  background: transparent; color: inherit;
  border: 1px solid currentColor; border-radius: 50%;
  opacity: .62; cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: opacity .2s ease;
}
.stage-pp:hover, .stage-pp:focus-visible { opacity: 1; }
.stage-pp:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
.stage-pp svg { width: 18px; height: 18px; display: none; }
.stage-pp[data-state="playing"] .pp-pause,
.stage-pp[data-state="paused"] .pp-play,
.stage-pp[data-state="done"] .pp-again { display: block; }

/* ---------- reduced motion ---------- */
/* Reveal first, then stop the world. Order matters: the blanket
   transition:none must not land before .fade has been made visible. */
@media (prefers-reduced-motion: reduce) {
  html.fx .fade,
  html.fx .reveal,
  html.fx .img-ease,
  .flip .fw,
  .flip .fw-em { opacity: 1 !important; transform: none !important;
    clip-path: none !important; scale: 1 !important; }
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
    scroll-behavior: auto !important;
  }
}

/* ---------- ?nofx=1 : deterministic screenshots and QA ---------- */
.no-fx .fade,
.no-fx .reveal,
.no-fx .img-ease,
.no-fx .fw,
.no-fx .fw-em { opacity: 1 !important; transform: none !important; transition: none !important;
  clip-path: none !important; scale: 1 !important; }
.no-fx *, .no-fx *::before, .no-fx *::after { animation: none !important; }

/* ---------- the escape hatch ---------- */
/* motion.js removes html.fx if it ever fails, and the boot snippet removes it
   if motion.js never reported in. Both land here: no hidden states at all. */
