/**
 * Jalpi Page Builder — Frontend interactivity.
 *
 * All widgets use data-* attributes for configuration so the renderer only
 * needs to output the right HTML attributes instead of inline <script> blocks.
 *
 * Each init function is guarded by a DOM selector check so it runs only when
 * the matching widget type is present on the page.
 */
(function () {
  'use strict';

  /* ── Helpers ─────────────────────────────────────────────────────── */

  function pad(n) {
    return String(n).padStart(2, '0');
  }

  function fmtNumber(n, sep) {
    if (!sep) return String(n);
    return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, sep);
  }

  /* ── Testimonials Carousel ──────────────────────────────────────── */

  function initCarousels() {
    document.querySelectorAll('.jalpi-testimonials--carousel').forEach(function (tr) {
      var tx = 0,
        delay = parseInt(tr.dataset.autoplay || '0', 10),
        timer = 0;

      tr.addEventListener('touchstart', function (e) { tx = e.touches[0].clientX; }, { passive: true });
      tr.addEventListener('touchend', function (e) {
        var dx = e.changedTouches[0].clientX - tx;
        if (Math.abs(dx) < 30) return;
        scroll(dx < 0 ? 1 : -1);
      }, { passive: true });

      function scroll(dir) {
        var card = tr.querySelector('.jalpi-tcard');
        if (!card) return;
        var gap = parseFloat(getComputedStyle(tr).gap) || 20;
        var step = card.offsetWidth + gap;
        var max = tr.scrollWidth - tr.clientWidth;
        if (dir > 0 && tr.scrollLeft >= max - 2) { tr.scrollLeft = 0; }
        else { tr.scrollBy({ left: dir * step, behavior: 'smooth' }); }
      }

      if (delay > 0) {
        function play() { timer = setInterval(function () { scroll(1); }, delay); }
        function pause() { clearInterval(timer); }
        play();
        tr.addEventListener('mouseenter', pause);
        tr.addEventListener('mouseleave', play);
        tr.addEventListener('touchstart', pause, { passive: true });
        tr.addEventListener('touchend', function () { setTimeout(play, delay); }, { passive: true });
      }
    });
  }

  /* ── Tabs ────────────────────────────────────────────────────────── */

  function initTabs() {
    document.querySelectorAll('.jalpi-tabs').forEach(function (wrap) {
      var btns = wrap.querySelectorAll('.jalpi-tabs__btn');
      var panes = wrap.querySelectorAll('.jalpi-tabs__pane');
      btns.forEach(function (btn, i) {
        btn.addEventListener('click', function () {
          btns.forEach(function (b) { b.classList.remove('is-active'); b.setAttribute('aria-selected', 'false'); });
          panes.forEach(function (p) { p.hidden = true; });
          btn.classList.add('is-active'); btn.setAttribute('aria-selected', 'true');
          if (panes[i]) panes[i].hidden = false;
        });
      });
      if (btns[0]) { btns[0].click(); }
    });
  }

  /* ── Countdown ──────────────────────────────────────────────────── */

  function initCountdowns() {
    document.querySelectorAll('.jalpi-countdown[data-target]').forEach(function (el) {
      var target = new Date(el.dataset.target).getTime();
      var days = el.querySelector('.jalpi-cd__days');
      var hours = el.querySelector('.jalpi-cd__hours');
      var mins = el.querySelector('.jalpi-cd__mins');
      var secs = el.querySelector('.jalpi-cd__secs');

      // Guard: skip if the target date is invalid.
      if (isNaN(target)) {
        if (days) days.textContent = '--';
        if (hours) hours.textContent = '--';
        if (mins) mins.textContent = '--';
        if (secs) secs.textContent = '--';
        return;
      }

      function tick() {
        var diff = target - Date.now();
        if (diff <= 0) { if (days) days.textContent = '00'; if (hours) hours.textContent = '00'; if (mins) mins.textContent = '00'; if (secs) secs.textContent = '00'; return; }
        if (days) days.textContent = pad(Math.floor(diff / 86400000));
        if (hours) hours.textContent = pad(Math.floor((diff / 3600000) % 24));
        if (mins) mins.textContent = pad(Math.floor((diff / 60000) % 60));
        if (secs) secs.textContent = pad(Math.floor((diff / 1000) % 60));
      }
      tick(); setInterval(tick, 1000);
    });
  }

  /* ── Counter (animated number) ──────────────────────────────────── */

  function initCounters() {
    function run(el) {
      var start = parseFloat(el.dataset.start) || 0;
      var end = parseFloat(el.dataset.end) || 0;
      var dur = parseInt(el.dataset.duration) || 2000;
      var sep = el.dataset.sep || '';
      var valEl = el.querySelector('.jalpi-counter__val');
      if (!valEl) return;
      var startTime = null;
      var isFloat = (end % 1 !== 0 || start % 1 !== 0);
      function step(ts) {
        if (!startTime) startTime = ts;
        var p = Math.min((ts - startTime) / dur, 1);
        var ease = p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2;
        var cur = start + (end - start) * ease;
        valEl.textContent = fmtNumber(isFloat ? cur.toFixed(2) : Math.round(cur), sep);
        if (p < 1) requestAnimationFrame(step);
        else valEl.textContent = fmtNumber(isFloat ? end.toFixed(2) : end, sep);
      }
      requestAnimationFrame(step);
    }
    if ('IntersectionObserver' in window) {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) { if (e.isIntersecting) { run(e.target); io.unobserve(e.target); } });
      }, { threshold: 0.3 });
      document.querySelectorAll('.jalpi-counter__number[data-end]').forEach(function (el) { io.observe(el); });
    } else {
      document.querySelectorAll('.jalpi-counter__number[data-end]').forEach(run);
    }
  }

  /* ── Progress Bar ───────────────────────────────────────────────── */

  function initProgress() {
    function animate(fill) {
      fill.style.width = fill.dataset.pct + '%';
    }
    if ('IntersectionObserver' in window) {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (e.isIntersecting) { animate(e.target); io.unobserve(e.target); }
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('.jalpi-progress__fill[data-pct]').forEach(function (el) { io.observe(el); });
    } else {
      document.querySelectorAll('.jalpi-progress__fill[data-pct]').forEach(animate);
    }
  }

  /* ── FAQ Accordion ──────────────────────────────────────────────── */

  function initFaq() {
    document.querySelectorAll('.jalpi-faq').forEach(function (faq) {
      var multi = faq.hasAttribute('data-faq-multi');
      faq.querySelectorAll('.jalpi-faq__q').forEach(function (btn) {
        function toggle() {
          var item = btn.closest('.jalpi-faq__item');
          var wasOpen = item.classList.contains('is-open');
          if (!multi) {
            faq.querySelectorAll('.jalpi-faq__item.is-open').forEach(function (o) {
              o.classList.remove('is-open');
              o.querySelector('.jalpi-faq__q').setAttribute('aria-expanded', 'false');
            });
          }
          if (!wasOpen) {
            item.classList.add('is-open');
            btn.setAttribute('aria-expanded', 'true');
          } else if (multi) {
            item.classList.remove('is-open');
            btn.setAttribute('aria-expanded', 'false');
          }
        }
        btn.addEventListener('click', toggle);
        btn.addEventListener('keydown', function (e) {
          if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); }
        });
      });
    });
  }

  /* ── Image Carousel ─────────────────────────────────────────────── */

  function initImageCarousels() {
    document.querySelectorAll('.jalpi-img-carousel[data-jalpi-carousel]').forEach(function (root) {
      var track = root.querySelector('.jalpi-img-carousel__track');
      var slides = root.querySelectorAll('.jalpi-img-carousel__slide');
      if (!track || slides.length === 0) return;

      var prev = root.querySelector('.jalpi-img-carousel__prev');
      var next = root.querySelector('.jalpi-img-carousel__next');
      var dotsWrap = root.querySelector('.jalpi-img-carousel__dots');
      var total = slides.length;
      var cur = 0;
      var autoDelay = parseInt(root.dataset.autoplay || '0', 10);
      var timer = 0;
      var loop = root.dataset.loop !== 'false';

      function go(idx) {
        if (loop) { idx = ((idx % total) + total) % total; }
        else { idx = Math.max(0, Math.min(total - 1, idx)); }
        cur = idx;
        track.style.transform = 'translateX(-' + (cur * 100) + '%)';
        if (dotsWrap) {
          dotsWrap.querySelectorAll('.jalpi-img-carousel__dot').forEach(function (d, i) {
            d.classList.toggle('is-active', i === cur);
          });
        }
        if (!loop) {
          if (prev) prev.disabled = cur === 0;
          if (next) next.disabled = cur === total - 1;
        }
      }

      if (prev) prev.addEventListener('click', function () { go(cur - 1); });
      if (next) next.addEventListener('click', function () { go(cur + 1); });

      if (dotsWrap) {
        dotsWrap.querySelectorAll('.jalpi-img-carousel__dot').forEach(function (d, i) {
          d.addEventListener('click', function () { go(i); });
        });
      }

      // Swipe support
      var tx = 0;
      track.addEventListener('touchstart', function (e) { tx = e.touches[0].clientX; }, { passive: true });
      track.addEventListener('touchend', function (e) {
        var dx = e.changedTouches[0].clientX - tx;
        if (Math.abs(dx) > 40) go(dx < 0 ? cur + 1 : cur - 1);
      }, { passive: true });

      // Autoplay
      if (autoDelay > 0) {
        function play() { timer = setInterval(function () { go(cur + 1); }, autoDelay); }
        function pause() { clearInterval(timer); }
        play();
        root.addEventListener('mouseenter', pause);
        root.addEventListener('mouseleave', play);
        root.addEventListener('touchstart', pause, { passive: true });
        root.addEventListener('touchend', function () { setTimeout(play, autoDelay); }, { passive: true });
      }

      go(0);
    });
  }

  /* ── Gallery Filter ─────────────────────────────────────────────── */

  function initGalleryFilter() {
    document.querySelectorAll('[data-jalpi-gallery-filter]').forEach(function (gal) {
      var btns = gal.querySelectorAll('.jalpi-gallery__filter-btn');
      var items = gal.querySelectorAll('.jalpi-gallery__item');
      btns.forEach(function (btn) {
        btn.addEventListener('click', function () {
          var cat = btn.getAttribute('data-filter');
          btns.forEach(function (b) { b.classList.remove('jalpi-active'); });
          btn.classList.add('jalpi-active');
          items.forEach(function (item) {
            if (cat === 'all' || item.getAttribute('data-category') === cat) {
              item.classList.remove('jalpi-hidden');
            } else {
              item.classList.add('jalpi-hidden');
            }
          });
        });
      });
    });
  }

  /* ── Gallery Lightbox ──────────────────────────────────────────── */

  function initGalleryLightbox() {
    // Create a single reusable overlay element.
    var overlay = null;
    var lbImg = null;
    var closeBtn = null;

    function ensureOverlay() {
      if (overlay) return;
      overlay = document.createElement('div');
      overlay.id = 'jalpi-lightbox';
      overlay.setAttribute('role', 'dialog');
      overlay.setAttribute('aria-label', 'Image lightbox');
      overlay.style.cssText = 'position:fixed;inset:0;z-index:999999;background:rgba(0,0,0,0.9);display:none;align-items:center;justify-content:center;cursor:zoom-out;';

      closeBtn = document.createElement('button');
      closeBtn.setAttribute('aria-label', 'Close lightbox');
      closeBtn.style.cssText = 'position:absolute;top:16px;right:16px;background:none;border:none;color:#fff;font-size:32px;cursor:pointer;line-height:1;padding:8px;z-index:1;';
      closeBtn.textContent = '\u00D7';
      overlay.appendChild(closeBtn);

      lbImg = document.createElement('img');
      lbImg.style.cssText = 'max-width:90vw;max-height:90vh;object-fit:contain;border-radius:4px;';
      overlay.appendChild(lbImg);
      document.body.appendChild(overlay);

      function close() { overlay.style.display = 'none'; }
      closeBtn.addEventListener('click', function (e) { e.stopPropagation(); close(); });
      overlay.addEventListener('click', close);
      lbImg.addEventListener('click', function (e) { e.stopPropagation(); });
      document.addEventListener('keydown', function (ev) {
        if (ev.key === 'Escape' && overlay.style.display !== 'none') close();
      });
    }

    document.querySelectorAll('[data-jalpi-gallery-lightbox]').forEach(function (gal) {
      var items = gal.querySelectorAll('.jalpi-gallery__item[data-jalpi-lb]');
      items.forEach(function (item) {
        item.addEventListener('click', function (e) {
          e.preventDefault();
          var src = item.getAttribute('data-jalpi-lb');
          var alt = item.getAttribute('data-jalpi-lb-alt') || '';
          if (!src) return;
          ensureOverlay();
          lbImg.src = src;
          lbImg.alt = alt;
          overlay.style.display = 'flex';
          closeBtn.focus();
        });
      });
    });
  }

  /* ── Interaction Engine ──────────────────────────────────────────── */
  /* Reads the data-jalpi-interact='[...]' JSON payload the renderer injects
   * (Jalpi_Renderer::apply_interactions()) and drives it. Discrete triggers
   * toggle a data-jalpi-ia-active="ruleId ..." attribute that activates the
   * shared, parametrized @keyframes in frontend.css (pure CSS, GPU-accelerated
   * — no animation JS loop needed). Continuous triggers (mouseMove,
   * scrollProgress) write straight to el.style.transform inside a
   * requestAnimationFrame callback, always from a cached bounding rect, never
   * reading layout inside the loop. */

  function initInteractions() {
    var els = document.querySelectorAll('[data-jalpi-interact]');
    if (!els.length) return;

    var reduceMotion = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
    // The CSS guard (@media (prefers-reduced-motion: reduce)) already
    // neutralizes every animation/transition — skip attaching any triggers
    // at all so no JS work happens for users who asked for reduced motion.
    if (reduceMotion) return;

    var items = [];
    els.forEach(function (el) {
      var raw;
      try { raw = JSON.parse(el.getAttribute('data-jalpi-interact')); } catch (e) { raw = null; }
      if (raw && raw.length) items.push({ el: el, rules: raw });
    });
    if (!items.length) return;

    function toggleMarker(el, marker, on) {
      var cur = (el.getAttribute('data-jalpi-ia-active') || '').split(' ').filter(Boolean);
      var i = cur.indexOf(marker);
      if (on && i === -1) cur.push(marker);
      else if (!on && i !== -1) cur.splice(i, 1);
      el.setAttribute('data-jalpi-ia-active', cur.join(' '));
    }

    /* hover/parentHover play the "-out" marker's animation in reverse
     * (Jalpi_Renderer::add_interaction_keyframe_css()'s $reverse param), which
     * — combined with animation-fill-mode:both — ends at the SAME 0% keyframe
     * entrance actions use as their hidden/offset starting point. Left alone,
     * the element would stay stuck looking "vanished" forever after the mouse
     * leaves. Once that reverse playback actually finishes, clear the "-out"
     * marker so the CSS rule stops matching and the element falls back to its
     * plain, un-animated (fully visible) resting style. Ignored if a fresh
     * hover-in interrupts it first — toggleMarker already removed "-out" by
     * then, so this stale finish has nothing to clear.
     */
    function attachHoverOutCleanup(el, ruleId) {
      el.addEventListener('animationend', function (ev) {
        if (ev.target !== el) return;
        var active = (el.getAttribute('data-jalpi-ia-active') || '').split(' ').filter(Boolean);
        if (active.indexOf(ruleId + '-out') !== -1) toggleMarker(el, ruleId + '-out', false);
      });
    }

    /* "Peek on hover" (settings.hideAfterPlay): the element has no natural
     * resting visibility of its own here — Jalpi_Renderer emits a plain
     * `{selector}{opacity:0}` base rule for it (see maybe_add_interaction_css()),
     * so once the "-in" reveal finishes playing, clearing its marker is all
     * that's needed for that lower-specificity base rule to take back over
     * and hide the element again until the next hover. */
    function attachPeekOnHoverCleanup(el, ruleId) {
      el.addEventListener('animationend', function (ev) {
        if (ev.target !== el) return;
        toggleMarker(el, ruleId + '-in', false);
      });
    }

    /* -- scrollIntoView: one shared, fine-grained-threshold IntersectionObserver
     * for the common case (no start/end offset). A small dedicated observer is
     * only created per rule that actually sets an offset. -- */
    var ratioTargets = new Map(); // el -> [{ratio, marker, replay, played}]
    var THRESHOLD_STEPS = (function () { var a = []; for (var i = 0; i <= 20; i++) a.push(i / 20); return a; })();
    var sharedIo = ('IntersectionObserver' in window)
      ? new IntersectionObserver(function (entries) {
          entries.forEach(function (entry) {
            var targets = ratioTargets.get(entry.target);
            if (!targets) return;
            targets.forEach(function (t) {
              var visible = entry.isIntersecting && entry.intersectionRatio >= t.ratio;
              if (visible && !t.played) {
                toggleMarker(entry.target, t.marker, true);
                t.played = true;
                if (!t.replay) sharedIo.unobserve(entry.target);
              } else if (!visible && t.replay && t.played) {
                toggleMarker(entry.target, t.marker, false);
                t.played = false;
              }
            });
          });
        }, { threshold: THRESHOLD_STEPS })
      : null;

    function observeScrollIntoView(el, rule) {
      var s = rule.settings || {};
      var ratio = Math.max(0, Math.min(100, parseFloat(s.viewportPct || '10'))) / 100;
      var replay = !!s.replay;
      var hasOffset = !!(s.startOffset || s.endOffset);

      if (!sharedIo && !hasOffset) { toggleMarker(el, rule.id, true); return; } // no IO support — degrade to always-shown

      if (hasOffset) {
        var top = s.startOffset ? '-' + s.startOffset : '0px';
        var bottom = s.endOffset ? '-' + s.endOffset : '0px';
        var dedicated = new IntersectionObserver(function (entries) {
          entries.forEach(function (entry) {
            if (entry.isIntersecting) { toggleMarker(el, rule.id, true); if (!replay) dedicated.unobserve(el); }
            else if (replay) toggleMarker(el, rule.id, false);
          });
        }, { threshold: ratio, rootMargin: top + ' 0px ' + bottom + ' 0px' });
        dedicated.observe(el);
        return;
      }

      var list = ratioTargets.get(el) || [];
      list.push({ ratio: ratio, marker: rule.id, replay: replay, played: false });
      ratioTargets.set(el, list);
      sharedIo.observe(el);
    }

    /* -- scrollProgress: one shared, passive scroll listener flags "dirty";
     * the actual DOM write happens on the next rAF tick (classic
     * throttle-via-rAF), and only ever reads the cached rect, never layout,
     * inside that callback. -- */
    var progressItems = [];
    var progressTicking = false;

    function registerScrollProgress(el, rule) {
      var rect = null;
      function measure() { rect = el.getBoundingClientRect(); }
      measure();
      window.addEventListener('resize', measure, { passive: true });
      progressItems.push({ el: el, rule: rule, getRect: function () { return rect; } });
    }

    function buildProgressStyle(action, s, p) {
      var dist = parseFloat(s.distance) || 50;
      var scaleFrom = parseFloat(s.scaleValue) || 0.8;
      var rot = parseFloat(s.rotationDeg) || 15;
      var blurAmt = parseFloat(s.blurAmount) || 8;
      switch (action) {
        case 'slide':
        case 'translate':
          return { transform: 'translateY(' + ((1 - p) * dist) + 'px)', opacity: p };
        case 'scale':
        case 'zoom':
          return { transform: 'scale(' + (scaleFrom + (1 - scaleFrom) * p) + ')', opacity: p };
        case 'rotate':
          return { transform: 'rotate(' + ((1 - p) * -rot) + 'deg)', opacity: p };
        case 'blur':
          return { filter: 'blur(' + ((1 - p) * blurAmt) + 'px)' };
        default:
          return { opacity: p };
      }
    }

    function onScrollProgressFrame() {
      progressTicking = false;
      var vh = window.innerHeight;
      progressItems.forEach(function (item) {
        var rect = item.getRect();
        if (!rect) return;
        var s = item.rule.settings || {};
        var start = parseFloat(s.startOffset) || 0;
        var end = parseFloat(s.endOffset) || 0;
        var p = Math.max(0, Math.min(1, (vh - rect.top - start) / (vh + rect.height - start - end || 1)));
        var out = buildProgressStyle(item.rule.action, s, p);
        if (out.transform !== undefined) item.el.style.transform = out.transform;
        if (out.opacity !== undefined) item.el.style.opacity = String(out.opacity);
        if (out.filter !== undefined) item.el.style.filter = out.filter;
      });
    }
    function onScroll() {
      if (progressTicking) return;
      progressTicking = true;
      requestAnimationFrame(onScrollProgressFrame);
    }

    /* -- mouseMove: rAF-throttled parallax with an eased "current toward
     * target" loop (the `smoothness` setting). Rect is cached once + on
     * resize; the loop itself only ever writes transform, never reads
     * layout. -- */
    function registerMouseMove(el, rule) {
      var s = rule.settings || {};
      var moveX = parseFloat(s.moveX) || 20;
      var moveY = parseFloat(s.moveY) || 20;
      var strength = (parseFloat(s.strength) || 50) / 100;
      var reverse = s.reverse ? -1 : 1;
      var smoothness = Math.min(0.9, Math.max(0.02, (parseFloat(s.smoothness) || 15) / 100));

      var rect = null;
      function measure() { rect = el.getBoundingClientRect(); }
      measure();
      window.addEventListener('resize', measure, { passive: true });

      var targetX = 0, targetY = 0, curX = 0, curY = 0, raf = 0;

      function tick() {
        curX += (targetX - curX) * smoothness;
        curY += (targetY - curY) * smoothness;
        el.style.transform = 'translate(' + curX.toFixed(2) + 'px,' + curY.toFixed(2) + 'px)';
        if (Math.abs(targetX - curX) > 0.05 || Math.abs(targetY - curY) > 0.05) {
          raf = requestAnimationFrame(tick);
        } else {
          raf = 0;
        }
      }
      document.addEventListener('mousemove', function (e) {
        if (!rect) return;
        var cx = rect.left + rect.width / 2;
        var cy = rect.top + rect.height / 2;
        var dx = Math.max(-1, Math.min(1, (e.clientX - cx) / (rect.width / 2 || 1)));
        var dy = Math.max(-1, Math.min(1, (e.clientY - cy) / (rect.height / 2 || 1)));
        targetX = dx * moveX * strength * reverse;
        targetY = dy * moveY * strength * reverse;
        if (!raf) raf = requestAnimationFrame(tick);
      }, { passive: true });
      el.addEventListener('mouseleave', function () {
        targetX = 0; targetY = 0;
        if (!raf) raf = requestAnimationFrame(tick);
      });
    }

    /* -- tilt: 3D pointer tilt. Rotates the element around X/Y based on where
     * the pointer sits within it, easing back to flat on leave. Pointer-driven
     * only (no scroll reads), and every frame writes transform exclusively. -- */
    function registerTilt(el, rule) {
      var s = rule.settings || {};
      var maxDeg = parseFloat(s.maxDeg) || 12;
      var perspective = parseFloat(s.perspective) || 900;
      var scaleOn = parseFloat(s.scaleOn) || 1.02;
      var ease = Math.min(0.9, Math.max(0.02, (parseFloat(s.smoothness) || 18) / 100));
      var glare = !!s.glare;

      var tX = 0, tY = 0, cX = 0, cY = 0, tS = 1, cS = 1, raf = 0;
      el.style.transformStyle = 'preserve-3d';
      if (glare) el.classList.add('jalpi-tilt--glare');

      function tick() {
        cX += (tX - cX) * ease; cY += (tY - cY) * ease; cS += (tS - cS) * ease;
        el.style.transform = 'perspective(' + perspective + 'px) rotateX(' + cY.toFixed(2)
          + 'deg) rotateY(' + cX.toFixed(2) + 'deg) scale(' + cS.toFixed(3) + ')';
        if (Math.abs(tX - cX) > 0.02 || Math.abs(tY - cY) > 0.02 || Math.abs(tS - cS) > 0.001) {
          raf = requestAnimationFrame(tick);
        } else { raf = 0; }
      }
      el.addEventListener('mousemove', function (e) {
        var r = el.getBoundingClientRect();
        var px = (e.clientX - r.left) / (r.width || 1);
        var py = (e.clientY - r.top) / (r.height || 1);
        tX = (px - 0.5) * 2 * maxDeg;
        tY = -(py - 0.5) * 2 * maxDeg;
        tS = scaleOn;
        if (glare) {
          el.style.setProperty('--jalpi-glare-x', (px * 100).toFixed(1) + '%');
          el.style.setProperty('--jalpi-glare-y', (py * 100).toFixed(1) + '%');
        }
        if (!raf) raf = requestAnimationFrame(tick);
      }, { passive: true });
      el.addEventListener('mouseleave', function () {
        tX = 0; tY = 0; tS = 1;
        if (!raf) raf = requestAnimationFrame(tick);
      });
    }

    /* -- magnetic: element is pulled toward the pointer while it is nearby,
     * springing back on leave. `radius` is how far outside the element the
     * pull still applies. -- */
    function registerMagnetic(el, rule) {
      var s = rule.settings || {};
      var pull = parseFloat(s.pull) || 0.35;          // 0..1 of the offset
      var maxPull = parseFloat(s.maxPull) || 26;      // px cap
      var radius = parseFloat(s.radius) || 90;        // px outside the box
      var ease = Math.min(0.9, Math.max(0.02, (parseFloat(s.smoothness) || 20) / 100));

      var tX = 0, tY = 0, cX = 0, cY = 0, raf = 0;
      function tick() {
        cX += (tX - cX) * ease; cY += (tY - cY) * ease;
        el.style.transform = 'translate(' + cX.toFixed(2) + 'px,' + cY.toFixed(2) + 'px)';
        if (Math.abs(tX - cX) > 0.05 || Math.abs(tY - cY) > 0.05) raf = requestAnimationFrame(tick);
        else raf = 0;
      }
      document.addEventListener('mousemove', function (e) {
        var r = el.getBoundingClientRect();
        var cx = r.left + r.width / 2, cy = r.top + r.height / 2;
        var dx = e.clientX - cx, dy = e.clientY - cy;
        var inside = e.clientX >= r.left - radius && e.clientX <= r.right + radius
                  && e.clientY >= r.top - radius && e.clientY <= r.bottom + radius;
        if (inside) {
          tX = Math.max(-maxPull, Math.min(maxPull, dx * pull));
          tY = Math.max(-maxPull, Math.min(maxPull, dy * pull));
        } else { tX = 0; tY = 0; }
        if (!raf) raf = requestAnimationFrame(tick);
      }, { passive: true });
    }

    /* -- ripple: material-style click ripple. Spawns a span at the click point
     * and removes it once its CSS animation ends. -- */
    function registerRipple(el, rule) {
      var s = rule.settings || {};
      var color = s.color || 'rgba(255,255,255,.45)';
      var dur = parseFloat(s.duration) || 620;
      var cs = window.getComputedStyle(el);
      if (cs.position === 'static') el.style.position = 'relative';
      el.style.overflow = 'hidden';
      el.addEventListener('click', function (e) {
        var r = el.getBoundingClientRect();
        var size = Math.max(r.width, r.height) * 2;
        var sp = document.createElement('span');
        sp.className = 'jalpi-ripple';
        sp.style.cssText = 'position:absolute;border-radius:50%;pointer-events:none;transform:scale(0);'
          + 'background:' + color + ';width:' + size + 'px;height:' + size + 'px;'
          + 'left:' + (e.clientX - r.left - size / 2) + 'px;'
          + 'top:' + (e.clientY - r.top - size / 2) + 'px;'
          + 'animation:jalpi-ripple ' + dur + 'ms ease-out forwards;';
        el.appendChild(sp);
        setTimeout(function () { if (sp.parentNode) sp.parentNode.removeChild(sp); }, dur + 60);
      });
    }

    /* -- pin: sticky-scroll. Holds the element in place for `distance` px of
     * scrolling by turning on position:sticky, and exposes 0..1 progress as
     * --jalpi-pin-progress so CSS can drive anything off it. -- */
    function registerPin(el, rule) {
      var s = rule.settings || {};
      var offset = parseFloat(s.offset) || 0;
      var distance = parseFloat(s.distance) || 400;

      var host = el.parentElement;
      if (!host) return;
      // Reserve the scroll distance on the parent so pinning does not collapse
      // the surrounding layout.
      var hostCs = window.getComputedStyle(host);
      if (hostCs.position === 'static') host.style.position = 'relative';
      host.style.minHeight = (el.offsetHeight + distance) + 'px';

      el.style.position = 'sticky';
      el.style.top = offset + 'px';

      var ticking = false;
      function update() {
        ticking = false;
        var r = host.getBoundingClientRect();
        var travelled = Math.min(distance, Math.max(0, offset - r.top));
        var p = distance ? travelled / distance : 0;
        el.style.setProperty('--jalpi-pin-progress', p.toFixed(4));
        el.setAttribute('data-jalpi-pinned', p > 0 && p < 1 ? 'true' : 'false');
      }
      window.addEventListener('scroll', function () {
        if (!ticking) { ticking = true; requestAnimationFrame(update); }
      }, { passive: true });
      window.addEventListener('resize', function () {
        host.style.minHeight = (el.offsetHeight + distance) + 'px';
      }, { passive: true });
      update();
    }

    /* -- hscroll: horizontal scroll section. While the element is pinned, its
     * inner track translates sideways in step with vertical scroll. The child
     * track is the element's first child. -- */
    function registerHScroll(el, rule) {
      var s = rule.settings || {};
      var track = el.firstElementChild;
      if (!track) return;
      var offset = parseFloat(s.offset) || 0;

      var host = el.parentElement || el;
      var hostCs = window.getComputedStyle(host);
      if (hostCs.position === 'static') host.style.position = 'relative';

      el.style.position = 'sticky';
      el.style.top = offset + 'px';
      el.style.overflow = 'hidden';
      track.style.display = 'flex';
      track.style.willChange = 'transform';

      function distance() { return Math.max(0, track.scrollWidth - el.clientWidth); }

      /* Vertical scroll distance reserved for the sideways travel.
       *
       * Reserving the FULL horizontal overflow 1:1 makes a long track create an
       * enormous empty column below the cards (a 356px-tall strip with 1782px
       * of overflow reserved 2138px, leaving ~1400px of blank page). `speed`
       * scales how much vertical scrolling maps to the horizontal travel:
       * 1 = 1:1, 0.5 = travel twice as fast for half the page height. It is
       * also capped so a very wide track can never dominate the page. */
      var speed = parseFloat(s.speed);
      if (!(speed > 0)) speed = 0.4;
      var maxVh = parseFloat(s.maxVh);
      if (!(maxVh > 0)) maxVh = 0.62; // at most ~0.6 viewport heights of scroll

      function reserved() {
        var cap = window.innerHeight * maxVh;
        return Math.min(distance() * speed, cap);
      }
      function layout() { host.style.minHeight = (el.offsetHeight + reserved()) + 'px'; }
      layout();

      var ticking = false;
      function update() {
        ticking = false;
        var d = distance();          // full horizontal overflow to cover
        var res = reserved();        // vertical scroll actually reserved
        var r = host.getBoundingClientRect();
        var scrolled = Math.max(0, offset - r.top);
        // Map the (shorter) vertical travel onto the full horizontal distance
        // so the track still finishes exactly at the last card.
        var p = res > 0 ? Math.min(1, scrolled / res) : 0;
        var travelled = d * p;
        track.style.transform = 'translateX(' + (-travelled).toFixed(1) + 'px)';
        el.style.setProperty('--jalpi-hscroll-progress', p.toFixed(4));
      }
      window.addEventListener('scroll', function () {
        if (!ticking) { ticking = true; requestAnimationFrame(update); }
      }, { passive: true });
      window.addEventListener('resize', function () { layout(); update(); }, { passive: true });
      update();
    }

    /* -- Attach every rule's trigger -- */
    items.forEach(function (item) {
      item.rules.forEach(function (rule) {
        switch (rule.trigger) {
          case 'pageLoad':
            // Defer two frames so the pre-animation state paints first,
            // otherwise the browser may coalesce it with the animated state.
            requestAnimationFrame(function () { requestAnimationFrame(function () { toggleMarker(item.el, rule.id, true); }); });
            break;
          case 'click':
            item.el.addEventListener('click', function () { toggleMarker(item.el, rule.id, true); });
            break;
          case 'doubleClick':
            item.el.addEventListener('dblclick', function () { toggleMarker(item.el, rule.id, true); });
            break;
          case 'focus':
            item.el.addEventListener('focus', function () { toggleMarker(item.el, rule.id, true); });
            break;
          case 'blur':
            item.el.addEventListener('blur', function () { toggleMarker(item.el, rule.id, true); });
            break;
          case 'mouseEnter':
            item.el.addEventListener('mouseenter', function () { toggleMarker(item.el, rule.id, true); });
            break;
          case 'mouseLeave':
            item.el.addEventListener('mouseleave', function () { toggleMarker(item.el, rule.id, true); });
            break;
          case 'hover':
            if ((rule.settings || {}).hideAfterPlay) {
              item.el.addEventListener('mouseenter', function () { toggleMarker(item.el, rule.id + '-in', true); });
              attachPeekOnHoverCleanup(item.el, rule.id);
            } else {
              item.el.addEventListener('mouseenter', function () { toggleMarker(item.el, rule.id + '-out', false); toggleMarker(item.el, rule.id + '-in', true); });
              item.el.addEventListener('mouseleave', function () { toggleMarker(item.el, rule.id + '-in', false); toggleMarker(item.el, rule.id + '-out', true); });
              attachHoverOutCleanup(item.el, rule.id);
            }
            break;
          case 'parentHover':
            // Same in/out marker pair as 'hover' (see maybe_add_interaction_
            // responsive_overrides()/add_interaction_keyframe_css() in
            // class-jalpi-renderer.php) — only the listener target differs:
            // the nearest structural ancestor instead of this node itself.
            (function () {
              var parent = item.el.parentElement ? item.el.parentElement.closest('.jalpi-section,.jalpi-container,.jalpi-column,.jalpi-el') : null;
              if (!parent) return;
              if ((rule.settings || {}).hideAfterPlay) {
                parent.addEventListener('mouseenter', function () { toggleMarker(item.el, rule.id + '-in', true); });
                attachPeekOnHoverCleanup(item.el, rule.id);
              } else {
                parent.addEventListener('mouseenter', function () { toggleMarker(item.el, rule.id + '-out', false); toggleMarker(item.el, rule.id + '-in', true); });
                parent.addEventListener('mouseleave', function () { toggleMarker(item.el, rule.id + '-in', false); toggleMarker(item.el, rule.id + '-out', true); });
                attachHoverOutCleanup(item.el, rule.id);
              }
            })();
            break;
          case 'scrollIntoView':
            observeScrollIntoView(item.el, rule);
            break;
          case 'scrollProgress':
            registerScrollProgress(item.el, rule);
            break;
          case 'mouseMove':
            registerMouseMove(item.el, rule);
            break;
          case 'tilt':
            registerTilt(item.el, rule);
            break;
          case 'magnetic':
            registerMagnetic(item.el, rule);
            break;
          case 'ripple':
            registerRipple(item.el, rule);
            break;
          case 'pin':
            registerPin(item.el, rule);
            break;
          case 'hscroll':
            registerHScroll(item.el, rule);
            break;
        }
      });
    });

    if (progressItems.length) {
      window.addEventListener('scroll', onScroll, { passive: true });
      onScroll();
    }
  }

  /* ── Bootstrap ──────────────────────────────────────────────────── */

  function init() {
    initCarousels();
    initTabs();
    initCountdowns();
    initCounters();
    initProgress();
    initFaq();
    initImageCarousels();
    initGalleryFilter();
    initGalleryLightbox();
    initInteractions();
    initMarquees();
    initTypewriters();
    initSplitText();
    initMenus();
    initAdvMenus();
    initScrollToTop();
    initReadingProgress();
    initTooltips();
    initCalculators();
    initYouTubeGalleries();
    initFacebookEmbeds();
    initInstagramEmbeds();
    initStickySections();
    initProductsGrid();
    initProductCategories();
    initSingleProduct();
  }

  /* ── WooCommerce Single Product ─────────────────────────────────────────
   * Gallery thumbnail switching, quantity steppers and AJAX add-to-cart for
   * SIMPLE products. Variable products render WooCommerce's own form, which
   * brings its own script — this deliberately does not touch it.
   * Everything degrades: with JS off the gallery shows the first image, the
   * quantity input still works, and the form posts normally. */
  function initSingleProduct() {
    var roots = document.querySelectorAll('[data-jalpi-sp]');
    if (!roots.length) return;

    roots.forEach(function (root) {
      var cfg;
      try { cfg = JSON.parse(root.getAttribute('data-jalpi-sp')); } catch (e) { cfg = {}; }

      /* Gallery: thumbs swap the active stage slide. */
      var thumbs = root.querySelectorAll('.jalpi-sp__thumb');
      var slides = root.querySelectorAll('.jalpi-sp__slide');
      if (thumbs.length && slides.length) {
        thumbs.forEach(function (btn) {
          btn.addEventListener('click', function () {
            var idx = btn.getAttribute('data-index');
            slides.forEach(function (sl) {
              sl.classList.toggle('is-active', sl.getAttribute('data-index') === idx);
            });
            thumbs.forEach(function (t) {
              var active = t === btn;
              t.classList.toggle('is-active', active);
              t.setAttribute('aria-selected', active ? 'true' : 'false');
            });
          });
        });
      }

      /* Quantity +/- stepper, clamped to the input's own min/max. */
      root.addEventListener('click', function (e) {
        var stepBtn = e.target.closest('.jalpi-sp__qty-btn');
        if (!stepBtn) return;
        e.preventDefault();
        var box = stepBtn.closest('.jalpi-sp__qty-box');
        var input = box && box.querySelector('.jalpi-sp__qty');
        if (!input) return;

        var step = parseInt(stepBtn.getAttribute('data-step'), 10) || 0;
        var min = parseInt(input.getAttribute('min'), 10);
        var max = parseInt(input.getAttribute('max'), 10);
        var val = parseInt(input.value, 10);
        if (isNaN(val)) val = isNaN(min) ? 1 : min;

        val += step;
        if (!isNaN(min) && val < min) val = min;
        if (!isNaN(max) && val > max) val = max;
        input.value = String(val);
        // Let WooCommerce/plugins observing the field react.
        input.dispatchEvent(new Event('change', { bubbles: true }));
      });

      /* AJAX add to cart (simple products only). */
      var pgCfg = window.jalpiPG || {};
      if (!cfg.ajax || !pgCfg.ajaxUrl) return;

      var form = root.querySelector('form.jalpi-sp__cart');
      var btn = form && form.querySelector('.jalpi-sp__btn--ajax');
      if (!form || !btn) return;

      form.addEventListener('submit', function (e) {
        e.preventDefault();
        if (btn.classList.contains('is-busy')) return;

        var qtyEl = form.querySelector('.jalpi-sp__qty');
        var label = btn.querySelector('.jalpi-sp__btn-text');
        var original = label ? label.textContent : '';

        btn.classList.add('is-busy');
        if (label) label.textContent = (pgCfg.i18n && pgCfg.i18n.loading) || 'Loading…';

        var body = new URLSearchParams();
        body.set('action', 'jalpi_pg_cart');
        body.set('nonce', pgCfg.nonce || '');
        body.set('product_id', btn.getAttribute('data-product-id'));
        body.set('quantity', qtyEl ? qtyEl.value : 1);

        fetch(pgCfg.ajaxUrl, {
          method: 'POST',
          credentials: 'same-origin',
          headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
          body: body.toString()
        })
          .then(function (r) { return r.json(); })
          .then(function (json) {
            var ok = !!(json && json.success);
            btn.classList.remove('is-busy');
            if (label) {
              label.textContent = ok
                ? ((pgCfg.i18n && pgCfg.i18n.added) || 'Added')
                : ((pgCfg.i18n && pgCfg.i18n.error) || 'Error');
              setTimeout(function () { label.textContent = original; }, 1800);
            }
            if (ok) {
              document.body.dispatchEvent(new CustomEvent('added_to_cart', { bubbles: true }));
              if (window.jQuery) window.jQuery(document.body).trigger('wc_fragment_refresh');
            }
          })
          .catch(function () {
            // Network failure: fall back to a real form submit rather than
            // leaving the visitor with a dead button.
            btn.classList.remove('is-busy');
            if (label) label.textContent = original;
            form.submit();
          });
      });
    });
  }

  /* ── WooCommerce Product Categories carousel ────────────────────────────
   * Only runs for the carousel layout (grid/list have no [data-jalpi-pc-carousel]).
   * Translates the flex track by whole pages; arrows/dots/autoplay/swipe mirror
   * the Image Carousel widget's behaviour. Degrades to a scrollable row with
   * JS off, since the track keeps its natural flex layout. */
  function initProductCategories() {
    var roots = document.querySelectorAll('[data-jalpi-pc-carousel]');
    if (!roots.length) return;

    roots.forEach(function (root) {
      var cfg;
      try { cfg = JSON.parse(root.getAttribute('data-jalpi-pc-carousel')); } catch (e) { return; }
      if (!cfg) return;

      var track = root.querySelector('.jalpi-pc__track');
      var items = root.querySelectorAll('.jalpi-pc__item');
      if (!track || !items.length) return;

      var prev = root.querySelector('.jalpi-pc__arrow--prev');
      var next = root.querySelector('.jalpi-pc__arrow--next');
      var dotsWrap = root.querySelector('.jalpi-pc__dots');

      var perView = Math.max(1, cfg.perView || 1);
      var step    = Math.max(1, cfg.scroll || 1);
      var loop    = cfg.loop !== false;
      var total   = items.length;
      // Pages of `step` slides, stopping when the last full view is shown.
      var pages   = Math.max(1, Math.ceil(Math.max(0, total - perView) / step) + 1);
      var cur     = 0;
      var timer   = 0;

      if (cfg.speed) track.style.transitionDuration = cfg.speed + 'ms';

      function go(idx) {
        if (loop) { idx = ((idx % pages) + pages) % pages; }
        else { idx = Math.max(0, Math.min(pages - 1, idx)); }
        cur = idx;

        // Offset by whole slides — measured so gaps are included.
        var first = items[0];
        var slideW = first.getBoundingClientRect().width;
        var gap = parseFloat(getComputedStyle(track).columnGap || getComputedStyle(track).gap || '0') || 0;
        var offset = Math.min(cur * step, Math.max(0, total - perView)) * (slideW + gap);
        track.style.transform = 'translateX(-' + offset + 'px)';

        if (dotsWrap) {
          dotsWrap.querySelectorAll('.jalpi-pc__dot').forEach(function (d, i) {
            d.classList.toggle('is-active', i === cur);
            d.setAttribute('aria-selected', i === cur ? 'true' : 'false');
          });
        }
        if (!loop) {
          if (prev) prev.disabled = cur === 0;
          if (next) next.disabled = cur === pages - 1;
        }
      }

      // Dots are built here (not in PHP) because the page count depends on
      // perView/scroll, which the server also knows — but building them client
      // side keeps the two from drifting when CSS changes the visible count.
      if (dotsWrap) {
        dotsWrap.innerHTML = '';
        for (var i = 0; i < pages; i++) {
          var dot = document.createElement('button');
          dot.type = 'button';
          dot.className = 'jalpi-pc__dot' + (i === 0 ? ' is-active' : '');
          dot.setAttribute('role', 'tab');
          dot.setAttribute('aria-label', 'Go to slide ' + (i + 1));
          (function (idx) {
            dot.addEventListener('click', function () { go(idx); restart(); });
          })(i);
          dotsWrap.appendChild(dot);
        }
      }

      if (prev) prev.addEventListener('click', function () { go(cur - 1); restart(); });
      if (next) next.addEventListener('click', function () { go(cur + 1); restart(); });

      // Swipe
      var tx = 0;
      track.addEventListener('touchstart', function (e) { tx = e.touches[0].clientX; }, { passive: true });
      track.addEventListener('touchend', function (e) {
        var dx = e.changedTouches[0].clientX - tx;
        if (Math.abs(dx) > 40) { go(dx < 0 ? cur + 1 : cur - 1); restart(); }
      }, { passive: true });

      // Autoplay
      function play() {
        if (!cfg.autoplay || cfg.autoplay < 1) return;
        if (prefersReducedMotion()) return;
        timer = setInterval(function () { go(cur + 1); }, cfg.autoplay);
      }
      function pause() { clearInterval(timer); timer = 0; }
      function restart() { pause(); play(); }

      if (cfg.pause !== false) {
        root.addEventListener('mouseenter', pause);
        root.addEventListener('mouseleave', play);
      }
      // Never animate off-screen or in a background tab.
      document.addEventListener('visibilitychange', function () {
        if (document.hidden) pause(); else play();
      });

      // Re-measure when the viewport changes (slide width is % based).
      var rt = 0;
      window.addEventListener('resize', function () {
        clearTimeout(rt);
        rt = setTimeout(function () { go(cur); }, 150);
      });

      go(0);
      play();
    });
  }

  /* ── WooCommerce Products Grid ──────────────────────────────────────────
   * Handles AJAX pagination (numbers / prev-next / load-more / infinite) and
   * AJAX add-to-cart. The server rebuilds the query from the SAVED node, so
   * only the post id, node id and page number are sent — never query args.
   * Degrades gracefully: with JS off, pagination buttons simply do nothing and
   * add-to-cart links fall back to their real ?add-to-cart= URLs. */
  function initProductsGrid() {
    var grids = document.querySelectorAll('[data-jalpi-pg]');
    if (!grids.length) return;

    var cfgGlobal = window.jalpiPG || {};
    if (!cfgGlobal.ajaxUrl) return;

    grids.forEach(function (grid) {
      var cfg;
      try { cfg = JSON.parse(grid.getAttribute('data-jalpi-pg')); } catch (e) { return; }
      if (!cfg) return;

      var gridEl = grid.querySelector('.jalpi-pg__grid');
      var nav    = grid.querySelector('.jalpi-pg__pagination');
      var page   = cfg.page || 1;
      var busy   = false;

      function post(action, data, done) {
        var body = new URLSearchParams();
        body.set('action', action);
        body.set('nonce', cfgGlobal.nonce || '');
        Object.keys(data).forEach(function (k) { body.set(k, data[k]); });

        fetch(cfgGlobal.ajaxUrl, {
          method: 'POST',
          credentials: 'same-origin',
          headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
          body: body.toString()
        })
          .then(function (r) { return r.json(); })
          .then(function (json) { done(json && json.success ? json.data : null); })
          .catch(function () { done(null); });
      }

      /* Load a page. `append` is true for load-more / infinite scroll. */
      function load(target, append) {
        if (busy || target < 1 || (cfg.pages && target > cfg.pages)) return;
        busy = true;
        grid.classList.add('is-loading');

        post('jalpi_pg_page', { post: cfg.post, node: cfg.node, page: target }, function (data) {
          busy = false;
          grid.classList.remove('is-loading');
          if (!data || typeof data.html !== 'string') return;

          if (append) {
            // insertAdjacentHTML keeps existing nodes (and their listeners).
            gridEl.insertAdjacentHTML('beforeend', data.html);
          } else {
            gridEl.innerHTML = data.html;
            // Bring the grid back into view after a page swap.
            var top = grid.getBoundingClientRect().top + window.pageYOffset - 80;
            window.scrollTo({ top: top, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
          }

          page = data.page || target;
          if (data.pages) cfg.pages = data.pages;
          syncNav();
        });
      }

      /* Reflect the current page in the pagination controls. */
      function syncNav() {
        if (!nav) return;

        nav.querySelectorAll('.jalpi-pg__page--num').forEach(function (btn) {
          var n = parseInt(btn.getAttribute('data-page'), 10);
          var active = n === page;
          btn.classList.toggle('is-active', active);
          if (active) { btn.setAttribute('aria-current', 'page'); }
          else { btn.removeAttribute('aria-current'); }
        });

        var prev = nav.querySelector('.jalpi-pg__page--prev');
        var next = nav.querySelector('.jalpi-pg__page--next');
        if (prev) { prev.disabled = page <= 1; prev.setAttribute('data-page', String(page - 1)); }
        if (next) { next.disabled = page >= cfg.pages; next.setAttribute('data-page', String(page + 1)); }

        var more = nav.querySelector('.jalpi-pg__loadmore');
        if (more) {
          // Last page reached — retire the button rather than leave a dead control.
          if (page >= cfg.pages) { more.remove(); }
          else { more.setAttribute('data-page', String(page + 1)); }
        }
      }

      /* Pagination clicks. */
      if (nav) {
        nav.addEventListener('click', function (e) {
          var btn = e.target.closest('.jalpi-pg__page, .jalpi-pg__loadmore');
          if (!btn || btn.disabled) return;
          e.preventDefault();
          var target = parseInt(btn.getAttribute('data-page'), 10);
          if (!isNaN(target)) {
            load(target, btn.classList.contains('jalpi-pg__loadmore'));
          }
        });
      }

      /* Infinite scroll — IntersectionObserver on the sentinel. */
      var sentinel = grid.querySelector('.jalpi-pg__sentinel');
      if (sentinel && 'IntersectionObserver' in window) {
        var io = new IntersectionObserver(function (entries) {
          entries.forEach(function (entry) {
            if (!entry.isIntersecting || busy) return;
            if (page >= cfg.pages) { io.disconnect(); return; }
            load(page + 1, true);
          });
        }, { rootMargin: '200px' });
        io.observe(sentinel);
      }

      /* AJAX add-to-cart (delegated, so cards added later work too). */
      grid.addEventListener('click', function (e) {
        var btn = e.target.closest('.jalpi-pg__btn--ajax');
        if (!btn) return;
        e.preventDefault();
        if (btn.classList.contains('is-busy')) return;

        var item = btn.closest('.jalpi-pg__item');
        var qtyEl = item ? item.querySelector('.jalpi-pg__qty') : null;
        var label = btn.querySelector('.jalpi-pg__btn-text');
        var original = label ? label.textContent : '';

        btn.classList.add('is-busy');
        if (label) label.textContent = (cfgGlobal.i18n && cfgGlobal.i18n.loading) || 'Loading…';

        post('jalpi_pg_cart', {
          product_id: btn.getAttribute('data-product-id'),
          quantity: qtyEl ? qtyEl.value : 1
        }, function (data) {
          btn.classList.remove('is-busy');
          var ok = !!data;
          if (label) {
            label.textContent = ok
              ? ((cfgGlobal.i18n && cfgGlobal.i18n.added) || 'Added')
              : ((cfgGlobal.i18n && cfgGlobal.i18n.error) || 'Error');
            setTimeout(function () { label.textContent = original; }, 1800);
          }
          if (ok) {
            // Let WooCommerce (and any theme mini-cart) refresh its fragments.
            document.body.dispatchEvent(new CustomEvent('added_to_cart', { bubbles: true }));
            if (window.jQuery && data.fragments) {
              window.jQuery(document.body).trigger('wc_fragment_refresh');
            }
          }
        });
      });

      syncNav();
    });
  }

  /* ── Sticky Section (advanced JS mode) ──────────────────────────────────
   * Only runs for sections that opted into an advanced sticky option (hide-on-
   * scroll, show-on-scroll-up, animation, sticky-only bg/shadow, or a per-
   * device toggle). Plain CSS-sticky sections have no [data-jalpi-sticky] and
   * are untouched. Uses position:fixed + a placeholder spacer so layout height
   * is preserved, rAF-throttled passive scroll, and matchMedia for per-device
   * enable. Animations are transform/opacity only and skipped under reduced
   * motion. */
  function initStickySections() {
    var els = document.querySelectorAll('[data-jalpi-sticky]');
    if (!els.length) return;
    var reduce = prefersReducedMotion();

    els.forEach(function (el) {
      var cfg;
      try { cfg = JSON.parse(el.getAttribute('data-jalpi-sticky')); } catch (e) { return; }
      if (!cfg) return;

      var pos = cfg.position === 'bottom' ? 'bottom' : 'top';
      var offset = parseFloat(cfg.offset) || 0;
      var animation = reduce ? 'none' : (cfg.animation || 'none');
      var devices = cfg.devices || { desktop: true, tablet: true, mobile: true };
      var bp = cfg.breakpoints || { tablet: 1024, mobile: 767 };

      // Is sticky allowed at the current viewport width?
      function deviceAllowed() {
        var w = window.innerWidth;
        if (w <= bp.mobile) return devices.mobile !== false;
        if (w <= bp.tablet) return devices.tablet !== false;
        return devices.desktop !== false;
      }

      var placeholder = null;   // spacer that holds layout height while fixed
      var isFixed = false;
      var lastScroll = window.scrollY;
      var anchorTop = 0;        // the document Y where the element starts

      function measureAnchor() {
        // Measure the element's natural offset from the top of the document.
        if (isFixed && placeholder) {
          anchorTop = placeholder.getBoundingClientRect().top + window.scrollY;
        } else {
          anchorTop = el.getBoundingClientRect().top + window.scrollY;
        }
      }

      function makeFixed() {
        if (isFixed) return;
        var rect = el.getBoundingClientRect();
        placeholder = document.createElement('div');
        placeholder.style.height = rect.height + 'px';
        placeholder.setAttribute('aria-hidden', 'true');
        el.parentNode.insertBefore(placeholder, el);
        el.style.position = 'fixed';
        el.style[pos] = offset + 'px';
        el.style.left = rect.left + 'px';
        el.style.width = rect.width + 'px';
        el.classList.add('jalpi-sticky--stuck');
        if (cfg.stickyBg) el.style.setProperty('--jalpi-sticky-bg', cfg.stickyBg);
        if (cfg.stickyShadow) el.classList.add('jalpi-sticky--shadow');
        if (animation && animation !== 'none') el.classList.add('jalpi-sticky--anim-' + animation);
        isFixed = true;
      }

      function unmakeFixed() {
        if (!isFixed) return;
        el.style.position = '';
        el.style[pos] = '';
        el.style.left = '';
        el.style.width = '';
        el.classList.remove('jalpi-sticky--stuck', 'jalpi-sticky--hidden', 'jalpi-sticky--shadow', 'jalpi-sticky--anim-fade', 'jalpi-sticky--anim-slide');
        if (placeholder && placeholder.parentNode) placeholder.parentNode.removeChild(placeholder);
        placeholder = null;
        isFixed = false;
      }

      var ticking = false;
      function onScroll() {
        if (ticking) return;
        ticking = true;
        requestAnimationFrame(function () {
          ticking = false;
          if (!deviceAllowed()) { unmakeFixed(); return; }

          var y = window.scrollY;
          var scrollingDown = y > lastScroll;
          lastScroll = y;

          measureAnchor();
          var shouldStick = pos === 'top'
            ? y + offset >= anchorTop
            : true; // bottom-stick stays pinned to viewport bottom whenever scrolled

          if (shouldStick) {
            makeFixed();
            // hide-on-scroll-down / show-on-scroll-up
            if (cfg.hideDown && scrollingDown) {
              el.classList.add('jalpi-sticky--hidden');
            } else if (cfg.showUp && !scrollingDown) {
              el.classList.remove('jalpi-sticky--hidden');
            } else if (!cfg.hideDown) {
              el.classList.remove('jalpi-sticky--hidden');
            }
          } else {
            unmakeFixed();
          }
        });
      }

      // Keep fixed width/left correct on resize (and re-evaluate device gate).
      function onResize() {
        if (isFixed) {
          // recompute against the placeholder's current box
          var pr = placeholder ? placeholder.getBoundingClientRect() : el.getBoundingClientRect();
          el.style.left = pr.left + 'px';
          el.style.width = pr.width + 'px';
        }
        onScroll();
      }

      window.addEventListener('scroll', onScroll, { passive: true });
      window.addEventListener('resize', onResize, { passive: true });
      onScroll();
    });
  }

  /* ── YouTube gallery: click-to-load facade. No YouTube request happens
   * until the user clicks a thumbnail — then the thumbnail is swapped for the
   * privacy-enhanced (youtube-nocookie) iframe. Fast + no upfront 3rd-party JS. */
  function initYouTubeGalleries() {
    var items = document.querySelectorAll('.jalpi-yt-item');
    if (!items.length) return;
    items.forEach(function (btn) {
      btn.addEventListener('click', function () {
        var id = btn.getAttribute('data-id') || '';
        if (!/^[A-Za-z0-9_-]{11}$/.test(id)) return; // guard: only valid IDs
        var iframe = document.createElement('iframe');
        iframe.src = 'https://www.youtube-nocookie.com/embed/' + id + '?autoplay=1&rel=0';
        iframe.title = 'YouTube video player';
        iframe.setAttribute('frameborder', '0');
        iframe.setAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture');
        iframe.setAttribute('allowfullscreen', '');
        iframe.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:0;';
        // Replace the button's contents with the player.
        btn.innerHTML = '';
        btn.appendChild(iframe);
        btn.style.cursor = 'default';
      });
    });
  }

  /* ── Facebook / Instagram: lazy-load each platform SDK only when a widget is
   * present AND scrolled near, via IntersectionObserver. If the SDK is blocked
   * the server-rendered blockquote fallback (a link to the post) still shows. */
  function loadSdkOnce(id, src) {
    if (document.getElementById(id)) return;
    var s = document.createElement('script');
    s.id = id; s.async = true; s.defer = true; s.src = src; s.crossOrigin = 'anonymous';
    document.body.appendChild(s);
  }

  function lazyLoadOnView(els, cb) {
    if (!('IntersectionObserver' in window)) { cb(); return; }
    var io = new IntersectionObserver(function (entries) {
      for (var i = 0; i < entries.length; i++) {
        if (entries[i].isIntersecting) { cb(); io.disconnect(); return; }
      }
    }, { rootMargin: '200px' });
    els.forEach(function (el) { io.observe(el); });
  }

  function initFacebookEmbeds() {
    var els = document.querySelectorAll('.jalpi-fb-embed');
    if (!els.length) return;
    lazyLoadOnView(els, function () {
      // fb-root is required by the SDK.
      if (!document.getElementById('fb-root')) {
        var r = document.createElement('div'); r.id = 'fb-root'; document.body.appendChild(r);
      }
      loadSdkOnce('facebook-jssdk', 'https://connect.facebook.net/en_US/sdk.js#xfbml=1&version=v19.0');
      // If SDK already present, ask it to parse the new nodes.
      if (window.FB && window.FB.XFBML) { window.FB.XFBML.parse(); }
    });
  }

  function initInstagramEmbeds() {
    var els = document.querySelectorAll('.jalpi-ig-embed');
    if (!els.length) return;
    lazyLoadOnView(els, function () {
      loadSdkOnce('instagram-embed-js', 'https://www.instagram.com/embed.js');
      if (window.instgrm && window.instgrm.Embeds) { window.instgrm.Embeds.process(); }
    });
  }

  /* ── Schema-widget runtimes ──────────────────────────────────────────
   * Each self-guards by selector, so it no-ops on pages that don't use the
   * widget. All respect prefers-reduced-motion where they animate. */

  function prefersReducedMotion() {
    return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
  }

  /* ── Split Text ───────────────────────────────────────────────────────
   * Splits an element's text into per-character/word/line/paragraph pieces
   * and staggers an entrance animation across them.
   *
   * Design notes:
   *  - The server renders the REAL text. We split what's already in the DOM,
   *    so the text is crawlable and survives JS being unavailable.
   *  - The stagger is pure CSS: each piece gets --jalpi-st-i and the delay is
   *    a calc() in frontend.css. No per-piece timers, at any piece count.
   *  - One IntersectionObserver watches the ROOT, never the pieces.
   *  - The original markup is stashed so a split can be fully undone — the
   *    editor and any consumer that removes the node needs a real teardown.
   */

  var ST_PRESETS = {
    fadeUp:        { anim: 'jalpi-ia-slide',           vars: { '--jalpi-ia-ty-from': '30px' } },
    fadeDown:      { anim: 'jalpi-ia-slide',           vars: { '--jalpi-ia-ty-from': '-30px' } },
    fadeLeft:      { anim: 'jalpi-ia-slide',           vars: { '--jalpi-ia-tx-from': '-30px', '--jalpi-ia-ty-from': '0' } },
    fadeRight:     { anim: 'jalpi-ia-slide',           vars: { '--jalpi-ia-tx-from': '30px', '--jalpi-ia-ty-from': '0' } },
    zoomIn:        { anim: 'jalpi-ia-zoom',            vars: { '--jalpi-ia-scale-from': '0.3' } },
    zoomOut:       { anim: 'jalpi-ia-zoom',            vars: { '--jalpi-ia-scale-from': '1.6' } },
    rotate:        { anim: 'jalpi-ia-rotate',          vars: { '--jalpi-ia-rot-from': '-90deg' } },
    blurReveal:    { anim: 'jalpi-ia-blur',            vars: { '--jalpi-ia-blur-from': '10px' } },
    scale:         { anim: 'jalpi-ia-scale',           vars: { '--jalpi-ia-scale-from': '0.5' } },
    slide:         { anim: 'jalpi-ia-slide',           vars: { '--jalpi-ia-ty-from': '40px' } },
    flip:          { anim: 'jalpi-ia-flip',            vars: { '--jalpi-ia-flip-y-from': '90deg' } },
    elastic:       { anim: 'jalpi-st-elastic',         vars: { '--jalpi-st-scale-from': '0.4' } },
    bounce:        { anim: 'jalpi-ia-bounce',          vars: { '--jalpi-ia-ty-from': '-30px' } },
    typewriter:    { anim: 'jalpi-st-typewriter',      vars: {} },
    wave:          { anim: 'jalpi-st-wave',            vars: { '--jalpi-st-ty-from': '20px' } },
    maskReveal:    { anim: 'jalpi-ia-reveal',          vars: { '--jalpi-ia-clip-from': 'inset(0 0 100% 0)' } },
    clipReveal:    { anim: 'jalpi-ia-reveal',          vars: { '--jalpi-ia-clip-from': 'inset(0 100% 0 0)' } },
    glowReveal:    { anim: 'jalpi-st-glow',            vars: {} },
    letterSpacing: { anim: 'jalpi-st-letter-spacing',  vars: { '--jalpi-st-ls-from': '.5em' } },
    gradientReveal:{ anim: 'jalpi-st-gradient-reveal', vars: {} }
  };

  /* Typewriter reads as one char at a time, so a near-zero duration plus the
     stagger is what produces the effect — a long per-piece fade would blur it. */
  var ST_INSTANT = { typewriter: true };

  function stTextNodes(root) {
    var out = [];
    var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
      acceptNode: function (n) {
        return n.nodeValue && n.nodeValue.trim()
          ? NodeFilter.FILTER_ACCEPT
          : NodeFilter.FILTER_REJECT;
      }
    });
    var n;
    while ((n = walker.nextNode())) out.push(n);
    return out;
  }

  function stMakePiece(text, idx) {
    var s = document.createElement('span');
    s.className = 'jalpi-st__p';
    s.style.setProperty('--jalpi-st-i', String(idx));
    s.textContent = text;
    return s;
  }

  /* Splits in place, preserving the element's inline markup (<strong>, <a>, …)
     by walking text nodes rather than clobbering innerHTML. */
  function stSplit(el, mode) {
    var counter = { i: 0 };

    if (mode === 'paragraphs') {
      // Block-level children are the natural paragraph unit; fall back to the
      // whole element when there are none.
      var blocks = el.querySelectorAll('p, div, li, h1, h2, h3, h4, h5, h6');
      var list = blocks.length ? blocks : [el];
      Array.prototype.forEach.call(list, function (b, i) {
        b.classList.add('jalpi-st__p');
        b.style.setProperty('--jalpi-st-i', String(i));
      });
      return list.length;
    }

    stTextNodes(el).forEach(function (node) {
      var frag = document.createDocumentFragment();
      // Keep the leading/trailing whitespace that separated inline elements.
      var parts = node.nodeValue.split(/(\s+)/);

      parts.forEach(function (part) {
        if (!part) return;
        if (/^\s+$/.test(part)) { frag.appendChild(document.createTextNode(part)); return; }

        if (mode === 'chars') {
          // Chars nest inside a word wrapper so wrapping still breaks at word
          // boundaries — splitting to bare chars destroys word-wrap.
          var w = document.createElement('span');
          w.className = 'jalpi-st__w';
          Array.prototype.forEach.call(part, function (ch) {
            w.appendChild(stMakePiece(ch, counter.i++));
          });
          frag.appendChild(w);
        } else {
          frag.appendChild(stMakePiece(part, counter.i++));
        }
      });

      node.parentNode.replaceChild(frag, node);
    });

    return counter.i;
  }

  /* Lines can only be known after layout, so words are measured and then
     grouped by their rendered offsetTop. Re-run on resize / font load. */
  function stSplitLines(el) {
    // Measure with a plain word split first — line boxes only exist after layout.
    stSplit(el, 'words');
    var words = el.querySelectorAll('.jalpi-st__p');
    if (!words.length) return 0;

    var lines = [];
    var current = null;
    var lastTop = null;

    Array.prototype.forEach.call(words, function (w) {
      var top = w.offsetTop;
      if (lastTop === null || Math.abs(top - lastTop) > 1) {
        current = [];
        lines.push(current);
        lastTop = top;
      }
      current.push(w);
    });

    // A single measured line means the text never wrapped — the word split we
    // already have is the honest result, so keep it rather than faking lines.
    if (lines.length <= 1) return words.length;

    lines.forEach(function (group, li) {
      var first = group[0];
      var last = group[group.length - 1];
      var parent = first.parentNode;

      var line = document.createElement('span');
      line.className = 'jalpi-st__p jalpi-st__line';
      line.style.setProperty('--jalpi-st-i', String(li));
      parent.insertBefore(line, first);

      // Move the whole sibling RANGE, not just the word spans — the whitespace
      // text nodes between words are siblings too, and leaving them behind
      // would run the words together ("alphabeta" instead of "alpha beta").
      var node = first;
      while (node) {
        var next = node.nextSibling;
        line.appendChild(node);
        if (node === last) break;
        node = next;
      }

      // Only the line animates now; the inner word spans are just carriers.
      group.forEach(function (w) {
        w.classList.remove('jalpi-st__p');
        w.classList.add('jalpi-st__w');
        w.style.removeProperty('--jalpi-st-i');
      });
    });

    return lines.length;
  }

  function stApply(el, cfg) {
    var preset = ST_PRESETS[cfg.animation] || ST_PRESETS.fadeUp;

    // Original markup is the source of truth for every re-split and for destroy().
    if (el.__jalpiStOriginal == null) el.__jalpiStOriginal = el.innerHTML;
    else el.innerHTML = el.__jalpiStOriginal;

    var total = cfg.splitBy === 'lines' ? stSplitLines(el) : stSplit(el, cfg.splitBy);
    if (!total) return 0;

    el.style.setProperty('--jalpi-st-anim', preset.anim);
    el.style.setProperty('--jalpi-st-total', String(total));
    Object.keys(preset.vars).forEach(function (k) {
      el.style.setProperty(k, preset.vars[k]);
    });

    if (cfg.duration)   el.style.setProperty('--jalpi-st-dur', cfg.duration + 'ms');
    if (cfg.delay)      el.style.setProperty('--jalpi-st-delay', cfg.delay + 'ms');
    if (cfg.stagger !== undefined) el.style.setProperty('--jalpi-st-stagger', cfg.stagger + 'ms');
    if (cfg.ease)       el.style.setProperty('--jalpi-st-ease', cfg.ease);
    if (cfg.repeat)     el.style.setProperty('--jalpi-st-repeat', cfg.repeat === -1 ? 'infinite' : String(cfg.repeat));
    if (ST_INSTANT[cfg.animation]) el.style.setProperty('--jalpi-st-dur', '1ms');
    if (cfg.reverse)    el.classList.add('jalpi-st--reverse');
    if (cfg.gradient) {
      el.classList.add('jalpi-st--gradient');
      el.style.setProperty('--jalpi-st-gradient', cfg.gradient);
    }

    return total;
  }

  function stDestroy(el) {
    if (el.__jalpiStOriginal != null) {
      el.innerHTML = el.__jalpiStOriginal;
      delete el.__jalpiStOriginal;
    }
    if (el.__jalpiStRo) { el.__jalpiStRo.disconnect(); delete el.__jalpiStRo; }
    if (el.__jalpiStIo) { el.__jalpiStIo.disconnect(); delete el.__jalpiStIo; }
    el.removeAttribute('data-jalpi-st-active');
    el.classList.remove('jalpi-st--reverse', 'jalpi-st--gradient');
    delete el.__jalpiStDone;
  }

  function initSplitText() {
    var els = document.querySelectorAll('[data-jalpi-st]');
    if (!els.length) return;

    els.forEach(function (el) {
      if (el.__jalpiStDone) return; // never double-split
      el.__jalpiStDone = true;

      var cfg;
      try { cfg = JSON.parse(el.getAttribute('data-jalpi-st') || '{}'); }
      catch (e) { cfg = {}; }
      if (!cfg || !cfg.animation) return;

      // Screen readers should hear the sentence, not 200 separate letters.
      var original = (el.textContent || '').trim();
      if (original && !el.getAttribute('aria-label')) el.setAttribute('aria-label', original);

      // Reduced motion: never split at all. The untouched text is the correct
      // end state, and skipping the work means zero cost for these users.
      if (prefersReducedMotion()) return;

      if (!stApply(el, cfg)) return;

      var inner = el.firstElementChild ? el : null;
      if (inner) el.setAttribute('aria-hidden', 'false');
      // Pieces are decorative duplicates of the aria-label text.
      Array.prototype.forEach.call(el.querySelectorAll('.jalpi-st__p'), function (p) {
        p.setAttribute('aria-hidden', 'true');
      });

      var replay = !!cfg.replay;

      function play(on) {
        if (on) el.setAttribute('data-jalpi-st-active', '1');
        else el.removeAttribute('data-jalpi-st-active');
      }

      // On-load trigger: no observer needed at all.
      if (cfg.trigger === 'load') { play(true); }
      else if ('IntersectionObserver' in window) {
        var ratio = Math.max(0, Math.min(100, parseFloat(cfg.viewportPct || 20))) / 100;
        var opts = { threshold: ratio };
        if (cfg.offset) opts.rootMargin = '-' + cfg.offset + ' 0px -' + cfg.offset + ' 0px';

        var io = new IntersectionObserver(function (entries) {
          entries.forEach(function (entry) {
            if (entry.isIntersecting) {
              play(true);
              // Animate once unless replay is on — stop observing entirely.
              if (!replay) io.unobserve(el);
            } else if (replay) {
              play(false); // removing the marker restarts it on re-entry
            }
          });
        }, opts);
        io.observe(el);
        el.__jalpiStIo = io;
      } else {
        play(true); // no IO support: show the end state rather than nothing
      }

      // Line splitting depends on layout, so re-measure when the box changes.
      if (cfg.splitBy === 'lines' && 'ResizeObserver' in window) {
        var t = null;
        var lastW = el.offsetWidth;
        var ro = new ResizeObserver(function () {
          if (el.offsetWidth === lastW) return; // height-only changes aren't rewraps
          lastW = el.offsetWidth;
          clearTimeout(t);
          t = setTimeout(function () {
            var wasActive = el.hasAttribute('data-jalpi-st-active');
            stApply(el, cfg);
            Array.prototype.forEach.call(el.querySelectorAll('.jalpi-st__p'), function (p) {
              p.setAttribute('aria-hidden', 'true');
            });
            if (wasActive) play(true);
          }, 150);
        });
        ro.observe(el);
        el.__jalpiStRo = ro;
      }

      // Late-loading webfonts change metrics, which changes where lines break.
      if (cfg.splitBy === 'lines' && document.fonts && document.fonts.ready) {
        document.fonts.ready.then(function () {
          var wasActive = el.hasAttribute('data-jalpi-st-active');
          stApply(el, cfg);
          Array.prototype.forEach.call(el.querySelectorAll('.jalpi-st__p'), function (p) {
            p.setAttribute('aria-hidden', 'true');
          });
          if (wasActive) play(true);
        });
      }
    });
  }

  // Exposed so hosts that tear down DOM (the editor, SPA navigation) can undo
  // a split cleanly instead of leaking observers.
  window.jalpiSplitText = { init: initSplitText, destroy: stDestroy };

  /* ── Menu ─────────────────────────────────────────────────────────────
   * Multi-level navigation with a hamburger / off-canvas mobile panel.
   *
   * The markup is already a working keyboard-navigable link tree, and CSS
   * opens submenus on :hover and :focus-within. This script adds what CSS
   * cannot do: the collapsed-mode toggle, click/tap submenu expansion, edge
   * flipping, Escape-to-close, outside-click, and a focus trap while the
   * off-canvas panel is open.
   */

  /* ── Menu ─────────────────────────────────────────────────────────────
   * Navigation with dropdown submenus and a responsive collapsed panel.
   *
   * The markup is already a working keyboard-navigable link tree, and CSS
   * opens submenus on :hover and :focus-within when the trigger is "hover".
   * This script adds what CSS cannot: the collapsed-mode toggle, click-trigger
   * submenus, Escape-to-close and outside-click dismissal.
   */

  function initMenus() {
    var els = document.querySelectorAll('[data-jalpi-menu]');
    if (!els.length) return;

    els.forEach(function (nav) {
      var cfg;
      try { cfg = JSON.parse(nav.getAttribute('data-jalpi-menu') || '{}'); }
      catch (e) { cfg = {}; }

      var breakpoint = parseInt(cfg.breakpoint, 10) || 768;
      var responsive = cfg.responsive || 'dropdown';
      var trigger = cfg.trigger === 'click' ? 'click' : 'hover';

      var toggle = nav.querySelector('.jalpi-menu__toggle');
      var panel = nav.querySelector('.jalpi-menu__panel');
      if (!panel) return;

      /* ── Collapsed-mode tracking ─────────────────────────────────────
       * matchMedia rather than a resize listener: the callback fires only
       * when the breakpoint is actually crossed, not on every resize frame.
       */
      var mq = window.matchMedia('(max-width: ' + breakpoint + 'px)');

      function syncCollapsed() {
        var collapsed = mq.matches && responsive !== 'none';
        nav.setAttribute('data-collapsed', collapsed ? '1' : '0');

        // Leaving collapsed mode must not strand the page with an open panel
        // or stale expanded items.
        if (!collapsed) {
          closePanel(true);
          Array.prototype.forEach.call(
            nav.querySelectorAll('.jalpi-menu__item.is-open'),
            function (li) { setExpanded(li, false); }
          );
        }

        // The expand buttons are only reachable by keyboard in collapsed mode
        // or when the trigger is click; with hover on desktop :focus-within
        // already opens submenus, so an extra tab stop would be noise.
        Array.prototype.forEach.call(
          nav.querySelectorAll('.jalpi-menu__expand'),
          function (btn) {
            btn.setAttribute('tabindex', (collapsed || trigger === 'click') ? '0' : '-1');
          }
        );
      }

      if (mq.addEventListener) mq.addEventListener('change', syncCollapsed);
      else if (mq.addListener) mq.addListener(syncCollapsed); // Safari < 14
      syncCollapsed();

      /* ── Responsive panel open/close ─────────────────────────────────── */

      var lastFocused = null;

      function openPanel() {
        lastFocused = document.activeElement;
        nav.classList.add('is-open');
        if (toggle) toggle.setAttribute('aria-expanded', 'true');

        // Move focus into the panel so keyboard users land on the menu. The
        // panel animates out of visibility:hidden and a hidden element refuses
        // focus, so retry for a few frames until it takes.
        var first = panel.querySelector('.jalpi-menu__link');
        if (first) {
          var tries = 0;
          (function focusWhenReady() {
            if (!nav.classList.contains('is-open')) return;
            first.focus();
            if (document.activeElement === first || tries++ > 20) return;
            requestAnimationFrame(focusWhenReady);
          })();
        }
      }

      function closePanel(silent) {
        if (!nav.classList.contains('is-open')) return;
        nav.classList.remove('is-open');
        if (toggle) toggle.setAttribute('aria-expanded', 'false');

        // Return focus to the control that opened the panel, unless it is
        // closing because the viewport changed rather than a user action.
        if (!silent && lastFocused && lastFocused.focus) lastFocused.focus();
        lastFocused = null;
      }

      if (toggle) {
        toggle.addEventListener('click', function () {
          if (nav.classList.contains('is-open')) closePanel();
          else openPanel();
        });
      }

      /* ── Submenu expansion ───────────────────────────────────────────── */

      function setExpanded(li, open) {
        li.classList.toggle('is-open', open);
        var btn = li.querySelector(':scope > .jalpi-menu__expand');
        if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false');
      }

      function toggleItem(li) {
        var willOpen = !li.classList.contains('is-open');

        // Accordion behaviour: only one branch open per level, so a deep menu
        // cannot fill the panel with every submenu at once.
        var parentList = li.parentNode;
        if (parentList) {
          Array.prototype.forEach.call(parentList.children, function (sib) {
            if (sib !== li) setExpanded(sib, false);
          });
        }

        setExpanded(li, willOpen);
        if (willOpen) flip(li);
      }

      Array.prototype.forEach.call(
        nav.querySelectorAll('.jalpi-menu__expand'),
        function (btn) {
          btn.addEventListener('click', function (e) {
            e.preventDefault();
            e.stopPropagation();
            var li = btn.closest('.jalpi-menu__item');
            if (li) toggleItem(li);
          });
        }
      );

      // Click trigger: the parent LINK opens its submenu instead of following
      // its href. Only when the item actually has children, so ordinary links
      // still navigate.
      if (trigger === 'click') {
        Array.prototype.forEach.call(
          nav.querySelectorAll('.jalpi-menu__item--has-children'),
          function (li) {
            var link = li.querySelector(':scope > .jalpi-menu__link');
            if (!link) return;
            link.addEventListener('click', function (e) {
              if (nav.getAttribute('data-collapsed') === '1') return;
              e.preventDefault();
              toggleItem(li);
            });
          }
        );
      }

      /* ── Edge flipping ───────────────────────────────────────────────
       * A submenu near the right edge would otherwise overflow the viewport.
       * Measured on open rather than up front, because widths depend on fonts
       * and content that may not be ready at init.
       */
      function flip(li) {
        if (nav.getAttribute('data-collapsed') === '1') return;
        var sub = li.querySelector(':scope > .jalpi-menu__sub');
        if (!sub) return;

        sub.classList.remove('is-flipped');
        var rect = sub.getBoundingClientRect();
        if (rect.right > window.innerWidth - 8) sub.classList.add('is-flipped');
      }

      if (trigger === 'hover') {
        Array.prototype.forEach.call(
          nav.querySelectorAll('.jalpi-menu__item--has-children'),
          function (li) {
            // Hover and focus open submenus via CSS; flip on the same triggers
            // so the measurement happens exactly when one becomes visible.
            li.addEventListener('mouseenter', function () { flip(li); });
            li.addEventListener('focusin', function () { flip(li); });
          }
        );
      }

      /* ── Keyboard ────────────────────────────────────────────────────── */

      nav.addEventListener('keydown', function (e) {
        if (e.key !== 'Escape' && e.key !== 'Esc') return;

        // Close the innermost open thing first: submenu, then the panel.
        var openItem = nav.querySelector('.jalpi-menu__item.is-open');
        if (openItem) {
          setExpanded(openItem, false);
          var link = openItem.querySelector('.jalpi-menu__link');
          if (link) link.focus();
          return;
        }
        if (nav.classList.contains('is-open')) closePanel();
      });

      /* ── Outside click ───────────────────────────────────────────────── */

      document.addEventListener('click', function (e) {
        if (nav.contains(e.target)) return;

        Array.prototype.forEach.call(
          nav.querySelectorAll('.jalpi-menu__item.is-open'),
          function (li) { setExpanded(li, false); }
        );

        if (nav.getAttribute('data-collapsed') === '1') closePanel();
      });

      // Following a link inside the collapsed panel should dismiss it,
      // otherwise the panel stays open over same-page anchors.
      panel.addEventListener('click', function (e) {
        var link = e.target.closest ? e.target.closest('.jalpi-menu__link') : null;
        if (!link) return;
        if (nav.getAttribute('data-collapsed') !== '1') return;

        var href = link.getAttribute('href') || '';
        if (href && href !== '#') closePanel(true);
      });
    });
  }

  /* ── Advanced Menu ───────────────────────────────────────────────────────
   * Mega-panel navigation. Independent of initMenus(): it binds to a
   * different attribute and different classes, so the two elements never
   * touch each other's DOM.
   *
   * The element renders a complete, working <nav> on its own — this only
   * layers on hover intent, click triggering, the collapsed layouts and
   * keyboard support.
   */
  function initAdvMenus() {
    var els = document.querySelectorAll('[data-jalpi-advmenu]');
    if (!els.length) return;

    // NodeList and HTMLCollection are both iterated here; only the former has
    // a native forEach, so both go through Array.prototype.
    function forEach(list, fn) {
      Array.prototype.forEach.call(list || [], fn);
    }

    els.forEach(function (nav) {
      var cfg;
      try { cfg = JSON.parse(nav.getAttribute('data-jalpi-advmenu') || '{}'); }
      catch (e) { cfg = {}; }

      var breakpoint = parseInt(cfg.breakpoint, 10) || 1024;
      var trigger = cfg.trigger === 'click' ? 'click' : 'hover';
      var openDelay = Math.max(0, parseInt(cfg.openDelay, 10) || 0);
      var closeDelay = Math.max(0, parseInt(cfg.closeDelay, 10) || 0);
      var mobileLayout = cfg.mobileLayout || 'accordion';

      var toggle = nav.querySelector('.jalpi-advmenu__toggle');
      var panel = nav.querySelector('.jalpi-advmenu__panel');
      var closeBtn = nav.querySelector('.jalpi-advmenu__close');
      var backdrop = nav.querySelector('.jalpi-advmenu__backdrop');
      if (!panel) return;

      /* ── Collapsed-mode tracking ─────────────────────────────────────
       * matchMedia rather than a resize listener: the callback fires only
       * when the breakpoint is actually crossed, not on every resize frame.
       */
      var mq = window.matchMedia('(max-width: ' + breakpoint + 'px)');

      function isCollapsed() {
        return nav.getAttribute('data-collapsed') === '1';
      }

      function syncCollapsed() {
        var collapsed = mq.matches;
        nav.setAttribute('data-collapsed', collapsed ? '1' : '0');

        // Leaving collapsed mode must not strand the page with an open drawer
        // or stale expanded items.
        if (!collapsed) {
          closePanel(true);
          closeAll();
        }

        // The expand buttons are only reachable by keyboard in collapsed mode
        // or when the trigger is click; with hover on desktop :focus-within
        // already opens panels, so an extra tab stop would be noise.
        forEach(nav.querySelectorAll('.jalpi-advmenu__expand'), function (btn) {
          btn.setAttribute('tabindex', (collapsed || trigger === 'click') ? '0' : '-1');
        });
      }

      if (mq.addEventListener) mq.addEventListener('change', syncCollapsed);
      else if (mq.addListener) mq.addListener(syncCollapsed); // Safari < 14
      syncCollapsed();

      /* ── Item expansion ──────────────────────────────────────────────── */

      function setExpanded(li, open) {
        li.classList.toggle('is-open', open);
        var btn = li.querySelector(':scope > .jalpi-advmenu__expand');
        if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false');
      }

      function closeAll() {
        forEach(nav.querySelectorAll('.jalpi-advmenu__item.is-open'), function (li) {
          setExpanded(li, false);
        });
      }

      function toggleItem(li) {
        var willOpen = !li.classList.contains('is-open');

        // Accordion behaviour: only one branch open per level, so a deep menu
        // cannot fill the panel with every submenu at once.
        var parentList = li.parentNode;
        if (parentList) {
          forEach(parentList.children, function (sib) {
            if (sib !== li) setExpanded(sib, false);
          });
        }

        setExpanded(li, willOpen);
        if (willOpen) position(li);
      }

      /* ── Hover intent ────────────────────────────────────────────────
       * Open and close are both delayed so a pointer crossing the menu on its
       * way elsewhere does not flash every panel open, and so a diagonal
       * travel from the item to its panel does not close it mid-move.
       */
      var openTimer = null;
      var closeTimer = null;

      function clearTimers() {
        if (openTimer) { clearTimeout(openTimer); openTimer = null; }
        if (closeTimer) { clearTimeout(closeTimer); closeTimer = null; }
      }

      if (trigger === 'hover') {
        forEach(nav.querySelectorAll('.jalpi-advmenu__item--has-children'), function (li) {
          li.addEventListener('mouseenter', function () {
            if (isCollapsed()) return;
            clearTimers();
            openTimer = setTimeout(function () {
              forEach(li.parentNode ? li.parentNode.children : [], function (sib) {
                if (sib !== li) setExpanded(sib, false);
              });
              setExpanded(li, true);
              position(li);
            }, openDelay);
          });

          li.addEventListener('mouseleave', function () {
            if (isCollapsed()) return;
            clearTimers();
            closeTimer = setTimeout(function () {
              setExpanded(li, false);
            }, closeDelay);
          });

          // Keyboard focus opens immediately — a keyboard user has already
          // committed to the item, so a delay would only feel unresponsive.
          li.addEventListener('focusin', function () {
            if (isCollapsed()) return;
            clearTimers();
            setExpanded(li, true);
            position(li);
          });
        });
      }

      // Click trigger: the parent LINK opens its panel instead of following
      // its href. Only when the item actually has children, so ordinary links
      // still navigate.
      if (trigger === 'click') {
        forEach(nav.querySelectorAll('.jalpi-advmenu__item--has-children'), function (li) {
          var link = li.querySelector(':scope > .jalpi-advmenu__link');
          if (!link) return;
          link.addEventListener('click', function (e) {
            if (isCollapsed()) return;
            e.preventDefault();
            toggleItem(li);
          });
        });
      }

      // The expand button always toggles, under either trigger — it is the
      // only way to open a branch by keyboard in collapsed mode.
      forEach(nav.querySelectorAll('.jalpi-advmenu__expand'), function (btn) {
        btn.addEventListener('click', function (e) {
          e.preventDefault();
          e.stopPropagation();
          var li = btn.closest('.jalpi-advmenu__item');
          if (li) toggleItem(li);
        });
      });

      /* ── Edge positioning ────────────────────────────────────────────
       * A panel near the viewport edge would otherwise overflow. Measured on
       * open rather than up front, because widths depend on fonts and content
       * that may not be ready at init.
       */
      function position(li) {
        if (isCollapsed()) return;

        var sub = li.querySelector(':scope > .jalpi-advmenu__sub');
        if (sub) {
          sub.classList.remove('is-flipped');
          if (sub.getBoundingClientRect().right > window.innerWidth - 8) {
            sub.classList.add('is-flipped');
          }
        }

        // A container/custom-width mega panel is nudged back inside the
        // viewport; the full-width variant already spans it by construction.
        var mega = li.querySelector(':scope > .jalpi-advmenu__mega');
        if (mega && !mega.classList.contains('jalpi-advmenu__mega--full')) {
          mega.style.removeProperty('margin-inline-start');
          var rect = mega.getBoundingClientRect();
          var overflowRight = rect.right - (window.innerWidth - 8);
          if (overflowRight > 0) {
            mega.style.marginInlineStart = (-overflowRight) + 'px';
          } else if (rect.left < 8) {
            mega.style.marginInlineStart = (8 - rect.left) + 'px';
          }
        }
      }

      /* ── Responsive panel open/close ─────────────────────────────────── */

      var lastFocused = null;

      function openPanel() {
        lastFocused = document.activeElement;
        nav.classList.add('is-open');
        if (toggle) toggle.setAttribute('aria-expanded', 'true');
        if (backdrop) backdrop.hidden = false;

        // The off-canvas drawer covers the page, so the body behind it must
        // not scroll away under the user.
        if (mobileLayout === 'offcanvas') {
          document.body.style.overflow = 'hidden';
        }

        // Move focus into the panel so keyboard users land on the menu. The
        // panel animates out of visibility:hidden and a hidden element refuses
        // focus, so retry for a few frames until it takes.
        var first = panel.querySelector('.jalpi-advmenu__link');
        if (first) {
          var tries = 0;
          (function focusWhenReady() {
            if (!nav.classList.contains('is-open')) return;
            first.focus();
            if (document.activeElement === first || tries++ > 20) return;
            requestAnimationFrame(focusWhenReady);
          })();
        }
      }

      function closePanel(silent) {
        if (!nav.classList.contains('is-open')) return;
        nav.classList.remove('is-open');
        if (toggle) toggle.setAttribute('aria-expanded', 'false');
        if (backdrop) backdrop.hidden = true;
        document.body.style.removeProperty('overflow');

        // Return focus to the control that opened the panel, unless it is
        // closing because the viewport changed rather than a user action.
        if (!silent && lastFocused && lastFocused.focus) lastFocused.focus();
        lastFocused = null;
      }

      if (toggle) {
        toggle.addEventListener('click', function () {
          if (nav.classList.contains('is-open')) closePanel();
          else openPanel();
        });
      }

      if (closeBtn) closeBtn.addEventListener('click', function () { closePanel(); });
      if (backdrop) backdrop.addEventListener('click', function () { closePanel(); });

      /* ── Keyboard ────────────────────────────────────────────────────── */

      nav.addEventListener('keydown', function (e) {
        var key = e.key;

        if (key === 'Escape' || key === 'Esc') {
          // Close the innermost open thing first: submenu, then the panel.
          var openItem = nav.querySelector('.jalpi-advmenu__item.is-open');
          if (openItem) {
            setExpanded(openItem, false);
            var link = openItem.querySelector('.jalpi-advmenu__link');
            if (link) link.focus();
            return;
          }
          if (nav.classList.contains('is-open')) closePanel();
          return;
        }

        // Roving arrow navigation across the top-level items. Restricted to
        // the top level: inside a mega panel the content is arbitrary builder
        // widgets, where Tab is the correct traversal and hijacking arrows
        // would break inputs and textareas.
        if (key !== 'ArrowLeft' && key !== 'ArrowRight' &&
            key !== 'ArrowDown' && key !== 'ArrowUp') return;

        var current = e.target.closest ? e.target.closest('.jalpi-advmenu__item') : null;
        if (!current || current.parentNode !== nav.querySelector('.jalpi-advmenu__list')) return;

        var items = Array.prototype.slice.call(
          nav.querySelectorAll('.jalpi-advmenu__list > .jalpi-advmenu__item')
        );
        var idx = items.indexOf(current);
        if (idx === -1) return;

        // Down opens the current item's panel rather than moving along the bar.
        if (key === 'ArrowDown' && current.classList.contains('jalpi-advmenu__item--has-children')) {
          e.preventDefault();
          setExpanded(current, true);
          position(current);
          var inner = current.querySelector('.jalpi-advmenu__sub .jalpi-advmenu__link, .jalpi-advmenu__mega a');
          if (inner) inner.focus();
          return;
        }

        if (key === 'ArrowUp') {
          e.preventDefault();
          setExpanded(current, false);
          return;
        }

        // Left/Right wrap around the bar, mirrored under RTL so the arrows
        // always follow the visual direction of travel.
        var rtl = getComputedStyle(nav).direction === 'rtl';
        var forward = rtl ? (key === 'ArrowLeft') : (key === 'ArrowRight');
        e.preventDefault();
        var next = items[(idx + (forward ? 1 : -1) + items.length) % items.length];
        if (next) {
          var nextLink = next.querySelector('.jalpi-advmenu__link');
          if (nextLink) nextLink.focus();
        }
      });

      /* ── Outside click ───────────────────────────────────────────────── */

      document.addEventListener('click', function (e) {
        if (nav.contains(e.target)) return;
        clearTimers();
        closeAll();
        if (isCollapsed()) closePanel();
      });

      // Following a link inside the collapsed panel should dismiss it,
      // otherwise the panel stays open over same-page anchors.
      panel.addEventListener('click', function (e) {
        var link = e.target.closest ? e.target.closest('a') : null;
        if (!link || !isCollapsed()) return;

        var href = link.getAttribute('href') || '';
        if (href && href !== '#') closePanel(true);
      });
    });
  }

  function initMarquees() {
    var els = document.querySelectorAll('.jalpi-marquee');
    if (!els.length) return;
    els.forEach(function (el) {
      var track = el.querySelector('.jalpi-marquee__track');
      if (!track) return;
      if (prefersReducedMotion()) return; // leave static
      // Duplicate content so the loop is seamless.
      var clone = track.cloneNode(true);
      clone.setAttribute('aria-hidden', 'true');
      el.appendChild(clone);
      var speed = parseFloat(el.getAttribute('data-speed')) || 20;
      var dir = el.getAttribute('data-dir') === 'right' ? 1 : -1;
      var pause = el.getAttribute('data-pause') === '1';
      var w = track.offsetWidth;
      var x = dir === -1 ? 0 : -w;
      var paused = false;
      if (pause) {
        el.addEventListener('mouseenter', function () { paused = true; });
        el.addEventListener('mouseleave', function () { paused = false; });
      }
      var last = null;
      function step(ts) {
        if (last == null) last = ts;
        var dt = (ts - last) / 1000; last = ts;
        if (!paused) {
          x += dir * (w / speed) * dt;
          if (dir === -1 && x <= -w) x += w;
          if (dir === 1 && x >= 0) x -= w;
          track.style.transform = 'translateX(' + x.toFixed(2) + 'px)';
          clone.style.transform = 'translateX(' + (x + (dir === -1 ? w : -w)).toFixed(2) + 'px)';
        }
        requestAnimationFrame(step);
      }
      requestAnimationFrame(step);
    });
  }

  function initTypewriters() {
    var els = document.querySelectorAll('.jalpi-typewriter');
    if (!els.length) return;
    els.forEach(function (el) {
      var out = el.querySelector('.jalpi-typewriter__text');
      if (!out) return;
      var words;
      try { words = JSON.parse(el.getAttribute('data-words') || '[]'); } catch (e) { words = []; }
      if (!words.length) return;
      if (prefersReducedMotion()) { out.textContent = words[0]; return; }
      var speed = parseInt(el.getAttribute('data-speed'), 10) || 100;
      var loop = el.getAttribute('data-loop') === '1';
      var wi = 0, ci = 0, deleting = false;
      function tick() {
        var word = words[wi];
        out.textContent = word.slice(0, ci);
        if (!deleting && ci < word.length) { ci++; }
        else if (!deleting && ci === word.length) { deleting = true; setTimeout(tick, 1200); return; }
        else if (deleting && ci > 0) { ci--; }
        else { deleting = false; wi++; if (wi >= words.length) { if (!loop) return; wi = 0; } }
        setTimeout(tick, deleting ? speed / 2 : speed);
      }
      tick();
    });
  }

  function initScrollToTop() {
    var btns = document.querySelectorAll('.jalpi-scroll-top');
    if (!btns.length) return;
    btns.forEach(function (btn) {
      function toggle() { btn.style.display = window.scrollY > 300 ? 'inline-flex' : 'none'; }
      window.addEventListener('scroll', toggle, { passive: true });
      toggle();
      btn.addEventListener('click', function () {
        window.scrollTo({ top: 0, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
      });
    });
  }

  function initReadingProgress() {
    var bars = document.querySelectorAll('.jalpi-reading-progress');
    if (!bars.length) return;
    function update() {
      var h = document.documentElement.scrollHeight - window.innerHeight;
      var p = h > 0 ? (window.scrollY / h) * 100 : 0;
      bars.forEach(function (b) { b.style.width = Math.min(100, Math.max(0, p)) + '%'; });
    }
    window.addEventListener('scroll', update, { passive: true });
    window.addEventListener('resize', update, { passive: true });
    update();
  }

  function initTooltips() {
    // Tooltips are CSS-driven on hover/focus; JS only ensures the bubble is
    // positioned. Nothing required beyond the CSS, but we keep the hook so the
    // widget's needsJs flag has a real (if minimal) initializer.
    return;
  }

  function initCalculators() {
    var els = document.querySelectorAll('.jalpi-calc');
    if (!els.length) return;
    els.forEach(function (el) {
      var display = el.querySelector('.jalpi-calc__display');
      if (!display) return;
      var expr = '';
      function render() { display.textContent = expr || '0'; }
      el.addEventListener('click', function (e) {
        var btn = e.target.closest('.jalpi-calc__key');
        if (!btn) return;
        var k = btn.getAttribute('data-k');
        if (k === 'C') { expr = ''; }
        else if (k === '=') {
          try {
            // Only digits, operators, dot and parens are ever present (built
            // from fixed keys — no user free-text), so this is a bounded eval.
            var safe = expr.replace(/[^0-9+\-*/.() ]/g, '');
            /* eslint-disable no-new-func */
            var result = safe ? Function('"use strict";return (' + safe + ')')() : '';
            expr = (result === undefined || result === null) ? '' : String(result);
          } catch (err) { expr = 'Error'; }
        } else { expr += k; }
        render();
      });
      render();
    });
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
})();
