(function () {
// FireTail portfolio — shared image carousel for modal detail overlays.
// Extracted from PublishedWorkDetail.jsx so CaseStudy.jsx and
// CaseStudyDetail.jsx can reuse the same "peek" carousel whenever a project
// or case study has multiple images to show instead of a single cover.

// Transparent, wide (not icon-sized) click zones at each edge rather than a
// small button. Invisible at rest — only faded in while the carousel itself
// is hovered — since there's no peeking neighbor image to imply "there's
// more here" on its own; the arrows appearing on hover is what signals that
// instead.
function CarouselArrow({
  dir,
  visible,
  onClick
}) {
  const isPrev = dir === "prev";
  return /*#__PURE__*/React.createElement("button", {
    type: "button",
    "aria-label": isPrev ? "Previous image" : "Next image",
    onClick: e => {
      e.stopPropagation();
      onClick();
    },
    style: {
      position: "absolute",
      top: 0,
      bottom: 0,
      [isPrev ? "left" : "right"]: 0,
      width: "clamp(40px, 14%, 64px)",
      border: "none",
      padding: "0 8px",
      display: "flex",
      alignItems: "center",
      justifyContent: isPrev ? "flex-start" : "flex-end",
      background: `linear-gradient(to ${isPrev ? "right" : "left"}, rgba(6,8,16,0.5), transparent)`,
      color: "var(--paper-50)",
      cursor: "pointer",
      fontFamily: "var(--font-mono)",
      fontSize: 26,
      lineHeight: 1,
      opacity: visible ? 1 : 0,
      pointerEvents: visible ? "auto" : "none",
      transition: "opacity var(--dur-base) var(--ease-out)"
    }
  }, isPrev ? "‹" : "›");
}

// Peek carousel — the active slide sits centered with neighbors partially
// visible at each edge. Sizing per image: max-width 85% alongside
// max-height 100% with width/height left as "auto" — a wide landscape image
// hits the width cap first and renders a bit shorter than full height
// (centered via alignItems), which is exactly what leaves peek room on its
// sides; a portrait image is nowhere near that cap, renders at full
// container height and stays narrow, so on an all-portrait set several
// neighbors may show at once — that's fine.
//
// Position is transform-driven (translateX on the track, computed from each
// slide's measured offsetLeft/offsetWidth so it centers correctly even at
// the array's real edges — no scroll clamping to fight), and the blur/dim
// below shares that same transition, so the "coming into focus" and "sliding
// into place" read as one motion instead of two independently-timed ones.
//
// Seamless looping: the rendered array is [last, ...images, first] — a clone
// of the opposite end at each side. Stepping past the real last image slides
// onto the cloned first (a normal animated move); once that transition ends,
// position snaps instantly (transition disabled for one frame, then
// re-enabled) to the real first image, which is pixel-identical to the
// clone, so the seam is invisible. Mirrored on the other end.
function ImageCarousel({
  images
}) {
  // Auto-advance keeps running under reduced motion (still a timeout-driven
  // slideshow) — only the slide/blur/opacity transitions themselves turn
  // off, so each advance is a snap cut instead of a slide.
  const reduced = typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  const containerRef = React.useRef(null);
  const slideRefs = React.useRef([]);
  const n = images.length;
  const loop = n > 1;
  const extended = loop ? [images[n - 1], ...images, images[0]] : images;
  const [position, setPosition] = React.useState(loop ? 1 : 0);
  const [animate, setAnimate] = React.useState(() => !reduced);
  const [offset, setOffset] = React.useState(0);
  // "Interacting" rather than plain hover — the mouse resting motionless
  // inside the div (user stepped away, or is just reading the modal text
  // below) shouldn't hold the arrows visible or auto-advance paused forever.
  // Any movement (including entering) marks active and restarts a short
  // idle countdown; leaving marks inactive immediately, no countdown needed.
  const [interacting, setInteracting] = React.useState(false);
  const idleTimerRef = React.useRef(null);
  const markActive = () => {
    setInteracting(true);
    if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
    idleTimerRef.current = setTimeout(() => setInteracting(false), 1500);
  };
  const markInactive = () => {
    if (idleTimerRef.current) {
      clearTimeout(idleTimerRef.current);
      idleTimerRef.current = null;
    }
    setInteracting(false);
  };
  // The 85% peek cap has to be a pixel value computed from the outer
  // container (which has a real, definite width), not a CSS percentage on
  // the image — the sliding track is intentionally width: max-content so it
  // can hold every slide side by side, and a percentage max-width resolved
  // against an indefinite containing block computes to none per spec, i.e.
  // silently does nothing. That's why images were filling the full width
  // again despite the max-width: 85% still being in the style.
  const [maxSlideWidth, setMaxSlideWidth] = React.useState(0);
  // Height used to be a flat landscape/portrait guess keyed off viewport
  // height alone (vh units) — fine on desktop, but on a narrow mobile
  // viewport the box stayed just as tall while the image itself hit the
  // width cap well before reaching that height, leaving empty letterboxing.
  // Deriving height from the actual measured aspect ratio and the container's
  // real width fixes that at any viewport size. Ratio is measured once from
  // whichever image loads first and locked — a project's screenshots are
  // consistently one orientation, so it shouldn't re-decide mid-carousel and
  // make the frame resize under the user.
  const [aspectRatio, setAspectRatio] = React.useState(null);
  const ratioLocked = React.useRef(false);
  const recompute = React.useCallback(pos => {
    const container = containerRef.current;
    const slide = slideRefs.current[pos];
    if (container) setMaxSlideWidth(container.clientWidth * 0.85);
    if (!container || !slide) return;
    setOffset(slide.offsetLeft - (container.clientWidth - slide.offsetWidth) / 2);
  }, []);
  const onSlideLoad = e => {
    if (!ratioLocked.current) {
      ratioLocked.current = true;
      setAspectRatio(e.target.naturalWidth / e.target.naturalHeight);
    }
    recompute(position);
  };

  // The frame's actual target height: whatever a slide at the 85%-of-width
  // peek cap would need to preserve its aspect ratio. Folded into the CSS
  // min() below alongside the viewport-relative/absolute caps, so whichever
  // constraint is tightest wins — for portrait content this number is huge
  // (narrow images "need" a lot of height for a given width) so the vh/px
  // caps end up winning as before; for landscape it's usually the tightest
  // of the three, which is what makes the frame actually track the real
  // available width instead of guessing from viewport height alone.
  const idealHeight = aspectRatio && maxSlideWidth ? Math.max(180, maxSlideWidth / aspectRatio) : null;
  React.useLayoutEffect(() => {
    recompute(position);
  }, [position, recompute]);
  React.useEffect(() => {
    const onResize = () => recompute(position);
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, [position, recompute]);

  // Auto-advance timer, held in a ref so it can be explicitly cleared and
  // restarted from step() below — not just implicitly via an effect
  // dependency — so a manual click always buys a full fresh 5s window
  // rather than possibly continuing a countdown already partway elapsed.
  const timerRef = React.useRef(null);
  const clearAutoTimer = () => {
    if (timerRef.current) {
      clearTimeout(timerRef.current);
      timerRef.current = null;
    }
  };
  const scheduleAutoAdvance = () => {
    clearAutoTimer();
    if (interacting || !loop) return;
    timerRef.current = setTimeout(() => step(1), 5000);
  };
  const step = delta => {
    if (reduced) {
      // No animation to hide the loop-seam clone behind, so jump straight
      // to the real wrapped index instead of stepping onto the clone and
      // relying on handleTransitionEnd (below) to relabel it — that snap
      // only ever fires off a transitionend event, which reduced motion
      // never produces.
      setPosition(p => ((p - 1 + delta) % n + n) % n + 1);
    } else {
      setAnimate(true);
      setPosition(p => p + delta);
    }
    scheduleAutoAdvance();
  };

  // Wheel = one step per gesture, not one step per event — a trackpad fires
  // many small deltaY events for a single physical swipe, so a raw 1:1
  // mapping would go flying through several slides at once. Locked out for
  // a beat after each step instead, matching "one scroll, one slide."
  //
  // Attached as a real native listener (not React's onWheel prop) with
  // { passive: false } — React registers its synthetic wheel handler as
  // passive, which silently makes e.preventDefault() a no-op, so the page
  // scrolled underneath no matter what the handler did. Only a genuinely
  // non-passive native listener actually blocks the scroll it's attached to,
  // and only while the pointer is over this element — scroll anywhere else
  // on the page is completely unaffected.
  const wheelLockRef = React.useRef(false);
  React.useEffect(() => {
    const el = containerRef.current;
    if (!el || !loop) return;
    const onWheel = e => {
      e.preventDefault();
      if (wheelLockRef.current || Math.abs(e.deltaY) < 4) return;
      wheelLockRef.current = true;
      step(e.deltaY > 0 ? 1 : -1);
      setTimeout(() => {
        wheelLockRef.current = false;
      }, 420);
    };
    el.addEventListener("wheel", onWheel, {
      passive: false
    });
    return () => el.removeEventListener("wheel", onWheel);
  }, [loop, step]);

  // Touch swipe — touch devices have neither a meaningful hover state (so
  // the arrows never reveal themselves) nor wheel events, so swipe is the
  // only way to navigate there. Mirrors the wheel handler above: read the
  // completed gesture and hand off to the existing step()/animation
  // machinery rather than live-dragging the track, which would have to
  // fight the seamless-loop clone logic further up in this file.
  //
  // Direction isn't decided until the touch has moved past a small
  // threshold, and only a horizontal drag calls preventDefault — an
  // intended vertical scroll (reading the page, not swiping the carousel)
  // is left alone. touchAction: "pan-y" on the container (below) backs this
  // up at the browser level, since without it some browsers commit to a
  // native scroll before the first touchmove handler even runs.
  const touchRef = React.useRef({
    x: 0,
    y: 0,
    tracking: false,
    axis: null
  });
  React.useEffect(() => {
    const el = containerRef.current;
    if (!el || !loop) return;
    const SWIPE_THRESHOLD = 40;
    const onTouchStart = e => {
      const t = e.touches[0];
      touchRef.current = {
        x: t.clientX,
        y: t.clientY,
        tracking: true,
        axis: null
      };
      markActive();
    };
    const onTouchMove = e => {
      const cur = touchRef.current;
      if (!cur.tracking) return;
      const t = e.touches[0];
      const dx = t.clientX - cur.x;
      const dy = t.clientY - cur.y;
      if (cur.axis === null && (Math.abs(dx) > 6 || Math.abs(dy) > 6)) {
        cur.axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y";
      }
      if (cur.axis === "x") e.preventDefault();
    };
    const onTouchEnd = e => {
      const cur = touchRef.current;
      if (!cur.tracking) return;
      cur.tracking = false;
      const t = e.changedTouches[0];
      const dx = t.clientX - cur.x;
      if (cur.axis === "x" && Math.abs(dx) > SWIPE_THRESHOLD) step(dx < 0 ? 1 : -1);
    };
    el.addEventListener("touchstart", onTouchStart, {
      passive: true
    });
    el.addEventListener("touchmove", onTouchMove, {
      passive: false
    });
    el.addEventListener("touchend", onTouchEnd, {
      passive: true
    });
    return () => {
      el.removeEventListener("touchstart", onTouchStart);
      el.removeEventListener("touchmove", onTouchMove);
      el.removeEventListener("touchend", onTouchEnd);
    };
  }, [loop, step]);

  // Only the track's own transform completing should trigger the clone-snap
  // check — filter/opacity transitions on child <img>s also bubble as
  // transitionend, and would otherwise false-trigger this.
  const handleTransitionEnd = e => {
    if (e.target !== e.currentTarget || e.propertyName !== "transform" || !loop) return;
    if (position === 0) {
      setAnimate(false);
      setPosition(n);
    } else if (position === n + 1) {
      setAnimate(false);
      setPosition(1);
    }
  };

  // Re-enable the transition only after the no-animation snap has actually
  // painted (double rAF) — re-enabling too early animates the snap itself,
  // which is exactly the seam this is meant to hide.
  React.useEffect(() => {
    if (animate || reduced) return;
    let raf2 = 0;
    const raf1 = requestAnimationFrame(() => {
      raf2 = requestAnimationFrame(() => setAnimate(true));
    });
    return () => {
      cancelAnimationFrame(raf1);
      cancelAnimationFrame(raf2);
    };
  }, [animate]);

  // Starts the loop on mount, and restarts it whenever interacting/loop
  // themselves change (mouse leaving, or the idle timeout firing) — every
  // other reset (manual clicks, each auto-tick) goes through step()'s
  // explicit call above.
  React.useEffect(() => {
    scheduleAutoAdvance();
    return clearAutoTimer;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [interacting, loop]);
  if (!images || images.length === 0) return null;
  return /*#__PURE__*/React.createElement("div", {
    ref: containerRef,
    onMouseEnter: markActive,
    onMouseMove: markActive,
    onMouseLeave: markInactive,
    style: {
      position: "relative",
      height: idealHeight ? `min(50vh, 420px, ${idealHeight}px)` : "min(50vh, 420px)",
      transition: "height var(--dur-slow) var(--ease-out)",
      borderRadius: "var(--radius-md)",
      border: "1px solid var(--border)",
      overflow: "hidden",
      background: "var(--ink-900)",
      touchAction: "pan-y"
    }
  }, /*#__PURE__*/React.createElement("div", {
    onTransitionEnd: handleTransitionEnd,
    style: {
      display: "flex",
      alignItems: "center",
      gap: 10,
      height: "100%",
      width: "max-content",
      transform: `translateX(${-offset}px)`,
      transition: animate ? "transform var(--dur-slow) var(--ease-out)" : "none"
    }
  }, extended.map((src, i) => /*#__PURE__*/React.createElement("img", {
    key: i,
    ref: el => {
      slideRefs.current[i] = el;
    },
    src: src,
    alt: "",
    loading: "lazy",
    onLoad: onSlideLoad,
    style: {
      maxHeight: "100%",
      maxWidth: maxSlideWidth || undefined,
      width: "auto",
      height: "auto",
      flexShrink: 0,
      display: "block",
      filter: i === position ? "none" : "blur(3px)",
      opacity: i === position ? 1 : 0.6,
      // Synced to the same `animate` flag as the track's transform
      // below — without this, the instant no-transition position
      // snap (clone → real slide at the loop seam) still left this
      // transition running at full duration, so the blur visibly
      // "replayed" on its own right as the frame silently jumped.
      transition: animate ? "filter var(--dur-slow) var(--ease-out), opacity var(--dur-slow) var(--ease-out)" : "none"
    }
  }))), loop && /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(CarouselArrow, {
    dir: "prev",
    visible: interacting,
    onClick: () => step(-1)
  }), /*#__PURE__*/React.createElement(CarouselArrow, {
    dir: "next",
    visible: interacting,
    onClick: () => step(1)
  })));
}
window.ImageCarousel = ImageCarousel;
})();
