/* ============================================================================
   IBIX motion kit — CSS half
   Load AFTER base/ibix-base.css. Nothing here works without it: this file
   extends the base's `.reveal` / `html.js` contract, it does not replace it.

       <link rel="stylesheet" href="/css/ibix-base.css">
       <link rel="stylesheet" href="/css/motion-kit.css">
       <link rel="stylesheet" href="/css/site.css">        <- your theme last

   Pairs with motion-kit.js. Every effect here has a finished-looking state with
   JS off and with reduced motion on — that is the whole design constraint.

   Sources: R 05 §2/§4/§8/§9, R 09 §4.1–§4.6, rulings D1/D2/D9/D10 and figures
   F7/F18/F21/F65 in design/research/_CANONICAL-FIGURES.md.
   ========================================================================= */


/* --- 1 · TOKENS ----------------------------------------------------------- */
/* Easing. The four `--ease-*` MD tokens are Material Design 3's published
   values (R 05 §4) — they are spec tokens, not folklore. --ease-reveal is the
   curve used throughout R 09 §4.2–§4.3. --ease-bounce is the verified linear()
   bounce from Chrome for Developers' linear() article (R 05 §4); cubic-bezier
   is monotonic by construction and CANNOT overshoot, so a real bounce needs
   linear(). Durations are [convention] — they match the base stylesheet's own
   .6s reveal and .18s control transition so nothing reads as two systems. */
:root {
  --ease-emphasized:        cubic-bezier(.2, 0, 0, 1);       /* content entering, hero reveals */
  --ease-emphasized-decel:  cubic-bezier(.05, .7, .1, 1);    /* elements entering the screen */
  --ease-emphasized-accel:  cubic-bezier(.3, 0, .8, .15);    /* elements leaving the screen */
  --ease-standard:          cubic-bezier(.4, 0, .2, 1);      /* general-purpose safe default */
  --ease-reveal:            cubic-bezier(.22, 1, .36, 1);    /* the house scroll-reveal curve */
  --ease-bounce: linear(
    0, 0.004, 0.016, 0.035, 0.063 9.1%, 0.141, 0.25, 0.391, 0.563, 0.765, 1,
    0.891, 0.813 45.5%, 0.785, 0.766, 0.754, 0.75, 0.754, 0.766, 0.785, 0.813 63.6%, 0.891, 1 72.7%,
    0.973, 0.953, 0.941, 0.938, 0.941, 0.953, 0.973, 1,
    0.988, 0.984, 0.988, 1
  );

  --dur-fast:    180ms;   /* [convention] matches .btn in ibix-base.css */
  --dur-base:    320ms;
  --dur-reveal:  600ms;   /* [convention] matches .reveal in ibix-base.css */
  --dur-slow:    900ms;
  --stagger-step: 90ms;   /* [convention] matches stagger(90) in R 09 §4.2 */

  /* Layer scale. z-index bugs have NO automatable checker anywhere in the
     industry (R 12 §12) — a written scale is the only mitigation. Use these,
     never a raw number, and never invent a value above --z-grain.
     [house doctrine] */
  --z-base:     1;
  --z-sticky:  40;   /* sticky mobile CTA (sticky-cta.css) */
  --z-header:  50;
  --z-overlay: 60;   /* mobile menu, modals, cookie banner */
  --z-cursor: 9998;  /* custom cursor, if used at all */
  --z-grain:  9999;  /* pointer-events:none, always */
}


/* --- 2 · REVEAL-ON-SCROLL (upgrade of the base contract) ------------------ */
/* ibix-base.css already ships the safe version:
       html.js .reveal      { opacity:0; transform:translateY(18px); transition:.6s ease }
       html.js .reveal.in   { opacity:1; transform:none }
   This block only swaps in the house easing and adds the group variants.
   The `html.js` gate is load-bearing: if JS is off, blocked, or main.js 404s,
   nothing is ever hidden. Put this in <head> BEFORE the stylesheets:
       <script>document.documentElement.classList.add('js');</script>
   ...and the noscript belt-and-braces rule the base stylesheet describes. */
html.js .reveal        { transition: opacity var(--dur-reveal) var(--ease-reveal),
                                     transform var(--dur-reveal) var(--ease-reveal); }

/* Staggered group. JS writes --i on each child (motion-kit.js). If anime.js is
   present the JS drives it instead and this delay is harmless — both paths end
   with `.in` on the children, so the final state is identical either way. */
html.js [data-stagger] > *        { opacity: 0; transform: translateY(18px);
                                    transition: opacity var(--dur-reveal) var(--ease-reveal),
                                                transform var(--dur-reveal) var(--ease-reveal);
                                    transition-delay: calc(var(--i, 0) * var(--stagger-step)); }
html.js [data-stagger].in > *,
html.js [data-stagger] > .in      { opacity: 1; transform: none; }

/* Image clip-reveal with scale-settle (R 09 §4.3, renamed `.is-in` -> `.in`
   so ONE class name drives every reveal in the house system). The wrapper needs
   a fixed aspect-ratio or the settle causes layout shift. */
.clip-reveal            { overflow: hidden; overflow: clip; }
html.js .clip-reveal    { clip-path: inset(0 0 100% 0); }
html.js .clip-reveal img{ transform: scale(1.12); }
html.js .clip-reveal.in { clip-path: inset(0);
                          transition: clip-path var(--dur-slow) var(--ease-reveal); }
html.js .clip-reveal.in img { transform: scale(1);
                          transition: transform 1.2s var(--ease-reveal); }


/* --- 3 · COUNT-UP STAT BAND ---------------------------------------------- */
/* The number must be in the HTML as text so it is correct with JS off:
       <span class="stat-num" data-count-to="120">120</span><span>+</span>
   tabular-nums stops the digits jittering as they change width; motion-kit.js
   also pins the measured width before it zeroes the value, so counting up
   causes no layout shift. */
.stat-num { font-variant-numeric: tabular-nums; display: inline-block; }


/* --- 4 · SVG LINE-DRAW ACCENT -------------------------------------------- */
/* Markup contract — pathLength="1" means no getTotalLength() call and no JS:
       <svg class="line-draw reveal" viewBox="0 0 200 40" aria-hidden="true">
         <path pathLength="1" d="M2 38 C 60 2, 140 2, 198 38" />
       </svg>
   Honest cost note: stroke-dashoffset is a PAINT property, not a compositor
   property (R 05 §9 — only transform/opacity composite in every engine). Keep
   the stroked box small and use this once or twice per page, never per card.
   [judgement — paint cost is real but bounded at this size] */
.line-draw path { fill: none; stroke: currentColor; stroke-width: 2;
                  stroke-linecap: round; }
html.js .line-draw path      { stroke-dasharray: 1; stroke-dashoffset: 1; }
html.js .line-draw.in path   { stroke-dashoffset: 0;
                               transition: stroke-dashoffset 1.1s var(--ease-emphasized); }


/* --- 5 · SCROLL-DRIVEN ANIMATION (progressive enhancement only) ---------- */
/* Support: Chrome/Edge 115+, Safari 26+. Firefox has NO shipped support — it is
   "preview"/Nightly-only in MDN browser-compat-data (D2). ~84% of global usage
   (F18, caniuse, Aug 2026, MEDIUM). Never write a Firefox version number for
   this feature: the "156" caniuse displays is a Nightly build number (banned
   figure B8).
   The @supports gate makes the fallback a genuine no-op — non-supporting
   browsers keep the static base state, which is already the finished state.
   No JS anywhere in this section. */

/* 5a. Depth drift — the Class-A substitute for WebGL parallax (R 09 §4.6).
       Keep the amplitude <=8%: past that it reads as a bug, not as depth.
       CONFLICT: never put .drift and micro-interactions' .zoom-crop on the same
       element. A running animation beats a normal declaration, so the drift
       keyframes overwrite the hover scale and the zoom silently stops working.
       Drift the wrapper or drift a different image — one transform owner per
       element, always. */
/*     There is deliberately no `will-change: transform` on .drift. A
       continuously transform-animated element gets a compositor layer anyway;
       a static will-change burns GPU memory on every card that uses it (D10).
       If real, measured jank appears, toggle it from JS around the animation. */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .drift { animation: ibix-drift linear both;
             animation-timeline: view();
             animation-range: entry 0% exit 100%; }
    @keyframes ibix-drift {
      from { transform: translateY(6%)  scale(1.06); }
      to   { transform: translateY(-6%) scale(1.06); }
    }
  }
}

/* 5b. Page scroll-progress bar (R 05 §2). Base state is scaleX(0) ONLY inside
       the supports gate, so unsupported browsers never see an empty bar. */
@supports (animation-timeline: scroll()) {
  @media (prefers-reduced-motion: no-preference) {
    html { scroll-timeline: --page-scroll block; }
    .scroll-progress { transform: scaleX(0); transform-origin: left center;
                       animation: ibix-grow linear both;
                       animation-timeline: --page-scroll; }
    [dir="rtl"] .scroll-progress { transform-origin: right center; }  /* transform has no logical form (R 11 §1) */
    @keyframes ibix-grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }
  }
}


/* --- 6 · CROSS-DOCUMENT PAGE TRANSITIONS --------------------------------- */
/* Two lines, zero JS, whole-site upgrade on a static MPA. Chrome/Edge 126+,
   Safari 18.2+, ~82% of global usage; Firefox simply performs a normal
   navigation (F16). Must be present in the CSS of BOTH the from- and to-page,
   which is why it lives in this shared file. */
@view-transition { navigation: auto; }
::view-transition-old(root),
::view-transition-new(root) { animation-duration: .35s; }


/* --- 6b · THE CSS FAILSAFE (the layer that needs no JavaScript at all) ---- */
/* THE HOLE THIS CLOSES. `html.js` protects against JS being OFF. It does not
   protect against JS being ON while motion-kit.js never arrives — a 404 after
   a bad deploy, a CSP that forgets the script, a corporate proxy, an
   extension. In that state the inline head script has already set `html.js`,
   every `.reveal` is at opacity 0, and nothing will ever add `.in`.
   Measured 2026-08-01 by aborting motion-kit.js at the network layer: ELEVEN
   elements stayed invisible permanently, including the entire hero. qa.py's
   no-JS sweep cannot see this, because in that sweep JS is disabled and the
   page is fine.

   The fix costs nothing and needs no script: motion-kit.js sets `html.mk` as
   its first statement, so `html.js:not(.mk)` means precisely "the stylesheet
   hid things and the script that un-hides them is missing". Four seconds
   later, a zero-length animation forces the finished state.

   Why an animation and not a transition: `animation-fill-mode: forwards` with
   a delay applies NOTHING during the delay and then pins the end state — so
   when the script IS present (the normal case) `html.mk` appears within
   milliseconds, the selector stops matching, and the real reveals run
   untouched. No animation is ever lost on a healthy page. */
@keyframes ibix-failsafe-fade  { to { opacity: 1; transform: none; } }
@keyframes ibix-failsafe-clip  { to { clip-path: inset(0); } }
@keyframes ibix-failsafe-scale { to { transform: scale(1); } }
@keyframes ibix-failsafe-draw  { to { stroke-dashoffset: 0; } }

html.js:not(.mk) .reveal,
html.js:not(.mk) [data-stagger] > *,
html.js:not(.mk) .hero-line > span { animation: ibix-failsafe-fade  1ms linear 4s forwards; }

/* `.clip-reveal` lists BOTH animations, and that is not belt-and-braces — it is
   required. An element carrying `clip-reveal reveal` (which is exactly what the
   hero patterns ship) matches this rule AND the one above, both of which set
   the `animation` SHORTHAND. A shorthand does not merge: the later rule
   replaces the earlier one wholesale, so the fade was silently dropped and the
   figure stayed at opacity 0 through the failsafe. Measured. Whenever two
   failsafe selectors can hit one element, the more specific rule has to carry
   the full animation list. */
html.js:not(.mk) .clip-reveal      { animation: ibix-failsafe-fade  1ms linear 4s forwards,
                                                ibix-failsafe-clip  1ms linear 4s forwards; }
html.js:not(.mk) .clip-reveal img  { animation: ibix-failsafe-scale 1ms linear 4s forwards; }
html.js:not(.mk) .line-draw path   { animation: ibix-failsafe-draw  1ms linear 4s forwards; }


/* --- 7 · REDUCED MOTION — MUST BE THE LAST BLOCK IN THIS FILE ------------ */
/* This repetition is not redundancy, it is a specificity fix. ibix-base.css
   ships its own reduced-motion override, but it loads FIRST, and every rule
   above has EQUAL specificity to it (html.js .reveal = 0,2,0). Later source
   order wins, so without this block motion-kit.css would silently re-enable
   motion for reduced-motion users — the exact failure the base stylesheet
   records against East Hope. Anything new added to this file gets a line here.
   prefers-reduced-motion has worked in every major browser since January 2020
   (F21) — there is no support excuse. */
@media (prefers-reduced-motion: reduce) {
  html.js .reveal, .reveal                { opacity: 1; transform: none; transition: none; }
  html.js [data-stagger] > *              { opacity: 1; transform: none;
                                            transition: none; transition-delay: 0s; }
  html.js .clip-reveal                    { clip-path: none; transition: none; }
  html.js .clip-reveal img                { transform: none; transition: none; }
  html.js .line-draw path,
  html.js .line-draw.in path              { stroke-dasharray: none; stroke-dashoffset: 0;
                                            transition: none; }
  .drift, .scroll-progress                { animation: none; transform: none; }
  /* The failsafe is not needed here — under reduced motion every rule above
     already resolves to the finished state — and leaving it running would
     re-apply an animation to a user who asked for none. */
  html.js:not(.mk) .reveal,
  html.js:not(.mk) [data-stagger] > *,
  html.js:not(.mk) .hero-line > span,
  html.js:not(.mk) .clip-reveal,
  html.js:not(.mk) .clip-reveal img,
  html.js:not(.mk) .line-draw path        { animation: none; }
  @view-transition                        { navigation: none; }
}
