(function () {
// FireTail portfolio — Work grid with genre filter
const {
  GameCard,
  Tag,
  Button
} = window.FireTailDesignSystem_df7a18;

// How many project cards show by default on mobile before "Show more" — see
// ProjectGrid's own comment on the cap for why this is mobile-only.
const FT_MOBILE_CARD_CAP = 6;

// Accent divider at the seam between the cover image and GameCard's own
// footer, scaling in from the left on hover, full card width edge-to-edge.
// GameCard's internal hover state isn't exposed, so hover (and the translateY
// lift it drives) is tracked independently on the wrapper instead, and synced
// onto this line so it doesn't stay behind while the card itself rises.
// Positioned by matching GameCard's own cover box (same aspect-ratio, full
// card width) rather than guessing a pixel offset from the bottom — the
// cover's height never varies, unlike the footer below it, which can grow to
// a second line of tags, so anchoring from the top tracks the real seam
// regardless of how much footer content there is.
function HoverSeamLine({
  visible
}) {
  return /*#__PURE__*/React.createElement("div", {
    "aria-hidden": "true",
    style: {
      position: "absolute",
      left: 0,
      right: 0,
      top: 0,
      aspectRatio: "16 / 10",
      zIndex: 0,
      pointerEvents: "none"
    }
  }, /*#__PURE__*/React.createElement("div", {
    style: {
      position: "absolute",
      left: 0,
      right: 0,
      bottom: 0,
      height: 2,
      background: "var(--accent)",
      boxShadow: "var(--glow-ember)",
      transform: visible ? "translateY(-4px) scaleX(1)" : "translateY(0) scaleX(0)",
      transformOrigin: "left",
      transition: "transform var(--dur-base) var(--ease-out)"
    }
  }));
}

// Accent glow ring around the whole card on hover — see PublishedWork.jsx's
// HoverGlow for the full rationale (mirrored by hand, same as HoverSeamLine).
// Uses the theme-aware var(--card-hover-glow) (tokens/colors.css), shared
// with CaseStudyCard and PublishedWork so all card hovers look the same.
// Must render AFTER <GameCard> below, not before — see PublishedWork.jsx's
// comment for why (GameCard's own transform pulls it into the same paint
// layer as positioned siblings, ordered by DOM position; placed first, its
// own hover border paints over this glow's border and masks it).
function HoverGlow({
  visible
}) {
  return /*#__PURE__*/React.createElement("div", {
    "aria-hidden": "true",
    style: {
      position: "absolute",
      inset: 0,
      zIndex: 0,
      pointerEvents: "none",
      borderRadius: "var(--radius-lg)",
      boxShadow: "var(--card-hover-glow)",
      border: "1px solid var(--accent-line)",
      opacity: visible ? 1 : 0,
      transform: visible ? "translateY(-4px)" : "translateY(0)",
      transition: "opacity var(--dur-base) var(--ease-out), transform var(--dur-base) var(--ease-out)"
    }
  });
}
function ProjectCard({
  project,
  onOpen
}) {
  const [hover, setHover] = React.useState(false);
  return /*#__PURE__*/React.createElement("div", {
    style: {
      position: "relative"
    },
    onMouseEnter: () => {
      setHover(true);
      window.FT_CARD_HOVERED = true;
    },
    onMouseLeave: () => {
      setHover(false);
      window.FT_CARD_HOVERED = false;
    }
  }, /*#__PURE__*/React.createElement(GameCard, {
    title: project.title,
    year: project.year,
    engine: project.engine,
    status: project.status,
    genres: project.genres,
    platforms: project.platforms,
    cover: project.cover,
    accent: project.accent,
    onClick: () => onOpen(project)
  }), /*#__PURE__*/React.createElement(HoverGlow, {
    visible: hover
  }), /*#__PURE__*/React.createElement(HoverSeamLine, {
    visible: hover
  }));
}

// Fades the horizontal genre-scroll strip at whichever edges still have
// content to scroll to — a hard-cut pill at the edge reads as clipped/broken,
// and a fade present at an edge with nothing left to scroll to reads as a
// dead end. Tracks the scroll container's own state rather than guessing.
function useEdgeFade(deps) {
  const ref = React.useRef(null);
  const [state, setState] = React.useState({
    left: false,
    right: false
  });
  const update = React.useCallback(() => {
    const el = ref.current;
    if (!el) return;
    setState({
      left: el.scrollLeft > 1,
      right: el.scrollLeft < el.scrollWidth - el.clientWidth - 1
    });
  }, []);
  React.useEffect(() => {
    update();
  }, deps); // eslint-disable-line react-hooks/exhaustive-deps
  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    el.addEventListener("scroll", update, {
      passive: true
    });
    window.addEventListener("resize", update);
    return () => {
      el.removeEventListener("scroll", update);
      window.removeEventListener("resize", update);
    };
  }, [update]);
  return [ref, state];
}
function ProjectGrid({
  onOpen
}) {
  const projects = window.FT_PROJECTS;
  const allGenres = ["All", ...Array.from(new Set(projects.flatMap(p => p.genres)))];
  const [filter, setFilter] = React.useState("All");
  const shown = filter === "All" ? projects : projects.filter(p => p.genres.includes(filter));
  // Every other genre besides whichever one is pinned (see below) — scrolls
  // horizontally in its own strip instead of wrapping the header down the page.
  const scrollGenres = allGenres.filter(g => g !== "All" && g !== filter);
  const [scrollRef, edgeFade] = useEdgeFade([scrollGenres.length]);

  // Pins the scroll strip's own box height to its at-rest measurement (taken
  // before any hover, so no on-hover scrollbar is factored in), plus an 8px
  // buffer — an explicit height, unlike "auto", can never grow to make room
  // for a scrollbar that appears later, so hovering the strip can no longer
  // push the grid below it down. The buffer gives the hover scrollbar its
  // own dedicated room to render into below the pills (paired with
  // alignItems: "flex-start" below) instead of carving space out of the
  // pills' own content area, which squeezed/misaligned them against the
  // static "All" pill beside it. Re-measures on resize (genre count/label
  // wrapping can change the strip's natural height at other viewport widths).
  const [scrollH, setScrollH] = React.useState(null);
  // useEffect, not useLayoutEffect — the initial measure() call below used to
  // run synchronously inside React's commit (blocking paint), which meant
  // its offsetHeight read was often the first layout-forcing read in a
  // commit where the whole rest of the page (Hero, grid, docks, etc.) was
  // also mounting at once — so it ate the cost of flushing ALL of that
  // pending layout, not just this one element's (confirmed via a CPU-
  // throttled profile: ~550-2000ms Layout+Recalculate-style depending on
  // throttle, all attributed to this one read). Deferring to useEffect lets
  // the browser paint first and settle layout on its own schedule, off the
  // critical path — the tradeoff is the pill strip briefly renders at its
  // natural "auto" height for one frame before snapping to the fixed
  // scrollH value, instead of that being guaranteed-never-visible.
  React.useEffect(() => {
    const el = scrollRef.current;
    if (!el) return;
    // Reset to "auto" before reading offsetHeight — once scrollH is applied
    // as this element's own explicit height, offsetHeight reflects that
    // previous output instead of the pills' natural size. Measuring without
    // resetting first would compound +8 on every call: a real click-drag
    // window resize fires many "resize" events (not one), so it would grow
    // unbounded over the course of a single drag rather than settling.
    const measure = () => {
      const prevHeight = el.style.height;
      // Only reset-to-auto when an explicit height is actually applied —
      // on the very first (mount) call none has been set yet, so offsetHeight
      // already reflects the natural size. Skipping the reset there turns
      // what used to be a guaranteed forced-reflow (write immediately
      // followed by a layout-dependent read) into a plain read on the exact
      // call that runs synchronously inside React's initial commit, when the
      // rest of the page is also laying out for the first time. Still needed
      // on later re-measurements (resize/filter change), where scrollH is
      // already applied as a fixed px height that would otherwise skew the
      // natural-size read.
      if (prevHeight) el.style.height = "auto";
      const natural = el.offsetHeight;
      if (prevHeight) el.style.height = prevHeight;
      setScrollH(natural + 8);
    };
    measure();
    window.addEventListener("resize", measure);
    return () => window.removeEventListener("resize", measure);
  }, [scrollGenres.length]);

  // Vertical wheel input over the strip otherwise just scrolls the page —
  // there's no vertical overflow here for it to act on. Redirect it into
  // horizontal scroll instead, native (not React's onWheel) so the listener
  // can be registered non-passive and preventDefault actually stops the
  // page from scrolling too. Only intercepts once there's real overflow to
  // scroll, so plain vertical scrolling still passes through once every
  // genre fits without a strip at all.
  React.useEffect(() => {
    const el = scrollRef.current;
    if (!el) return;
    const onWheel = e => {
      if (el.scrollWidth <= el.clientWidth) return;
      el.scrollLeft += e.deltaY;
      e.preventDefault();
    };
    el.addEventListener("wheel", onWheel, {
      passive: false
    });
    return () => el.removeEventListener("wheel", onWheel);
  }, [scrollGenres.length]);

  // Card count is capped on mobile only — desktop's multi-column grid barely
  // grows taller as more projects get added, but mobile's single column turns
  // every extra card into more scroll before reaching About. The cap applies
  // to whatever's currently shown (including "All"), not just the unfiltered
  // list — a broad genre most projects share (e.g. "Unity") barely shrinks the
  // list, so exempting filtered views from the cap wouldn't actually help.
  const [narrow, setNarrow] = React.useState(() => window.innerWidth < 768);
  React.useEffect(() => {
    const mq = window.matchMedia("(max-width: 767px)");
    const fn = e => setNarrow(e.matches);
    mq.addEventListener("change", fn);
    return () => mq.removeEventListener("change", fn);
  }, []);
  const [expanded, setExpanded] = React.useState(false);
  React.useEffect(() => {
    setExpanded(false);
  }, [filter]);
  const capped = narrow && !expanded && shown.length > FT_MOBILE_CARD_CAP;
  const visible = capped ? shown.slice(0, FT_MOBILE_CARD_CAP) : shown;

  // Pinning the filter panel only makes sense while there's still grid left
  // to scroll through underneath it. A row count alone doesn't capture
  // that — a 5-row grid is just as "out of content" as a 1-row one once
  // you've scrolled far enough that the *last* row is the one sitting under
  // the panel. So this tracks actual geometry: once the last card's top
  // edge has scrolled up to meet the panel's own bottom edge, there's
  // nothing left below worth pinning over, and the panel releases.
  const sectionRef = React.useRef(null);
  const panelRef = React.useRef(null);
  const lastCardRef = React.useRef(null);
  const [pinned, setPinned] = React.useState(true);
  // checkPin also tells Nav's shared frost layer (Nav.jsx) how much further
  // down to extend itself — dispatched via ft-frost-extend, not local state,
  // since nothing here needs to re-render off it (the actual blur/mask now
  // lives entirely in Nav; see Nav.jsx's frostExtra comment for why).
  //
  // The listeners driving checkPin only stay attached while this section is
  // anywhere near the viewport (IntersectionObserver below) — this used to
  // run on every scroll event for the entire session regardless of scroll
  // position, reading getComputedStyle/getBoundingClientRect and touching
  // React state (setPinned/setStuck) even scrolled deep into About or
  // Contact, nowhere near this grid.
  React.useEffect(() => {
    const section = sectionRef.current;
    if (!section) return;
    const scroller = document.querySelector("#ft-scroll") || window;
    let attached = false;
    const checkPin = () => {
      const panel = panelRef.current;
      const lastCard = lastCardRef.current;
      if (!panel || !lastCard) return;
      // Deliberately NOT panel.getBoundingClientRect().bottom here — the
      // panel's own live rect depends on whether it's currently pinned,
      // which is exactly what this decides. Reading it back would make the
      // toggle both the sensor and the actuator: flipping to "relative"
      // moves the panel, which changes the very rect the next check reads,
      // which can flip it straight back — a feedback loop that shows up as
      // flicker. Computing where the panel's bottom *would* sit if it were
      // stuck (nav offset + its own height, both independent of its current
      // pin state) keeps the decision a pure function of scroll position.
      const navH = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--nav-h")) || 0;
      const stuckBottom = navH + panel.offsetHeight;
      const lastCardTop = lastCard.getBoundingClientRect().top;
      const willPin = lastCardTop > stuckBottom;
      setPinned(willPin);
      const isStuck = willPin && panel.getBoundingClientRect().top <= navH + 1;
      window.dispatchEvent(new CustomEvent("ft-frost-extend", {
        detail: {
          height: isStuck ? panel.offsetHeight : 0
        }
      }));
    };

    // Watches the panel's own rendered size directly, rather than the
    // window's "resize" event — zoom doesn't reliably fire window resize in
    // every browser, and even when it does, it raced against the pill
    // strip's own resize-driven height measurement (see scrollH above),
    // occasionally reading panel.offsetHeight a tick before that had
    // settled. ResizeObserver fires only once layout has actually settled
    // to the panel's new size, whatever caused it — window resize, zoom, or
    // content reflow — so Nav's frost band can no longer be left sized to a
    // stale measurement from before the change.
    const resizeObserver = new ResizeObserver(() => checkPin());
    const attach = () => {
      if (attached) return;
      attached = true;
      // Scrolling happens inside #ft-scroll, not window (see App.jsx) — a
      // window scroll listener here would just sit dead and never fire.
      checkPin();
      scroller.addEventListener("scroll", checkPin, {
        passive: true
      });
      resizeObserver.observe(panelRef.current);
    };
    const detach = () => {
      if (!attached) return;
      attached = false;
      scroller.removeEventListener("scroll", checkPin);
      resizeObserver.disconnect();
      // Retract immediately — a stale "stuck" reading must never survive
      // past this section leaving view, or Nav's shared frost layer would
      // stay extended over whatever's now on screen somewhere else entirely.
      window.dispatchEvent(new CustomEvent("ft-frost-extend", {
        detail: {
          height: 0
        }
      }));
    };

    // Generous rootMargin so the listeners are already live (and checkPin
    // has already settled to the right answer) by the time the section is
    // actually visible, instead of the first scroll event after crossing
    // the threshold — same reasoning as HeroGame.jsx's own intersection
    // pause/resume, just with a buffer here since this drives Nav's shared
    // visual instead of just a background render loop.
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) attach();else detach();
    }, {
      rootMargin: "200px 0px 200px 0px"
    });
    observer.observe(section);
    return () => {
      observer.disconnect();
      detach();
    };
  }, [visible]);
  return /*#__PURE__*/React.createElement("section", {
    id: "work",
    ref: sectionRef,
    style: {
      padding: "clamp(40px,6vw,80px) 0",
      borderTop: "1px solid var(--border)"
    }
  }, /*#__PURE__*/React.createElement("div", {
    ref: panelRef,
    "data-reveal": true,
    style: {
      position: pinned ? "sticky" : "relative",
      top: pinned ? "var(--nav-h)" : 0,
      zIndex: 5
    }
  }, /*#__PURE__*/React.createElement("div", {
    style: {
      position: "relative",
      padding: "20px clamp(20px,5vw,48px) 0"
    }
  }, /*#__PURE__*/React.createElement("div", {
    style: {
      maxWidth: "var(--container)",
      margin: "0 auto"
    }
  }, /*#__PURE__*/React.createElement("div", {
    style: {
      display: "flex",
      justifyContent: "space-between",
      alignItems: "flex-end",
      gap: 24,
      flexWrap: "wrap",
      marginBottom: 16
    }
  }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", {
    style: {
      fontFamily: "var(--font-mono)",
      fontSize: 14,
      letterSpacing: "0.16em",
      textTransform: "uppercase",
      color: "var(--accent)"
    }
  }, "03 / Projects"), /*#__PURE__*/React.createElement("h2", {
    style: {
      margin: "12px 0 0",
      fontFamily: "var(--font-display)",
      fontWeight: 500,
      fontSize: "clamp(30px,4.5vw,62px)",
      letterSpacing: "-0.02em",
      color: "var(--text-strong)",
      lineHeight: 1.05
    }
  }, "Projects I've built."))), /*#__PURE__*/React.createElement("div", {
    style: {
      display: "flex",
      gap: 8,
      alignItems: "flex-start"
    }
  }, /*#__PURE__*/React.createElement("div", {
    style: {
      display: "flex",
      flexWrap: "wrap",
      gap: 8,
      flexShrink: 0,
      paddingTop: 6,
      paddingBottom: 6
    }
  }, /*#__PURE__*/React.createElement(Tag, {
    active: filter === "All",
    onClick: () => setFilter("All")
  }, "All"), filter !== "All" && /*#__PURE__*/React.createElement(Tag, {
    active: true,
    onClick: () => setFilter(filter)
  }, filter)), /*#__PURE__*/React.createElement("div", {
    style: {
      position: "relative",
      minWidth: 0
    }
  }, /*#__PURE__*/React.createElement("div", {
    ref: scrollRef,
    className: "ft-filter-scroll",
    style: {
      display: "flex",
      flexWrap: "nowrap",
      gap: 8,
      alignItems: "flex-start",
      overflowX: "auto",
      overflowY: "hidden",
      paddingBottom: 6,
      paddingTop: 6,
      ...(scrollH != null ? {
        height: scrollH
      } : {})
    }
  }, scrollGenres.map(g => /*#__PURE__*/React.createElement(Tag, {
    key: g,
    active: false,
    onClick: () => setFilter(g)
  }, g))), /*#__PURE__*/React.createElement("div", {
    "aria-hidden": "true",
    style: {
      position: "absolute",
      left: 0,
      top: 7,
      bottom: 17,
      width: 28,
      background: "linear-gradient(to right, var(--surface-overlay), transparent)",
      opacity: edgeFade.left ? 1 : 0,
      transition: "opacity var(--dur-base) var(--ease-out)",
      pointerEvents: "none"
    }
  }), /*#__PURE__*/React.createElement("div", {
    "aria-hidden": "true",
    style: {
      position: "absolute",
      right: 0,
      top: 7,
      bottom: 17,
      width: 28,
      background: "linear-gradient(to left, var(--surface-overlay), transparent)",
      opacity: edgeFade.right ? 1 : 0,
      transition: "opacity var(--dur-base) var(--ease-out)",
      pointerEvents: "none"
    }
  })))))), /*#__PURE__*/React.createElement("div", {
    style: {
      padding: "0 clamp(20px,5vw,48px)"
    }
  }, /*#__PURE__*/React.createElement("div", {
    style: {
      maxWidth: "var(--container)",
      margin: "36px auto 0"
    }
  }, /*#__PURE__*/React.createElement("div", {
    "data-reveal": true,
    style: {
      display: "grid",
      gridTemplateColumns: "repeat(auto-fill, minmax(320px, 1fr))",
      gap: 24,
      alignItems: "start"
    }
  }, visible.map((p, i) => /*#__PURE__*/React.createElement("div", {
    key: p.id,
    ref: i === visible.length - 1 ? lastCardRef : undefined
  }, /*#__PURE__*/React.createElement(ProjectCard, {
    project: p,
    onOpen: onOpen
  })))), capped && /*#__PURE__*/React.createElement("div", {
    style: {
      display: "flex",
      justifyContent: "center",
      marginTop: 32
    }
  }, /*#__PURE__*/React.createElement(Button, {
    variant: "ghost",
    onClick: () => setExpanded(true)
  }, "Show ", shown.length - FT_MOBILE_CARD_CAP, " more projects")))));
}
window.ProjectGrid = ProjectGrid;
})();
