(function () {
// FireTail portfolio — shared-element social dock (BottomDock), plus two
// separate, independent card-anchored icon rows (CardSocialDock for the
// About card, HeroCardSocialDock for the Hero card) and a standalone
// HeroScrollCue button.
//
// BottomDock is ONE element (icons + the arrow bubble together) with two
// scroll-driven homes:
//   float  → collapsed bubble bottom-right; fold-out on hover (desktop) / tap (mobile)
//   footer → travels into #ft-anchor-footer slot, auto-unfolded WITH labels
// Mode is computed each frame from anchor rects; the dock's RIGHT edge is pinned
// to the target right edge so fold-out width changes never shift it.
// Hidden entirely (opacity 0, no pointer events) while at the very top of the
// page — HeroScrollCue (below) covers "scroll to next section" there instead,
// so this dock only ever needs to handle "back to top" once it's visible.
// The arrow deliberately never travels into the About card — only
// CardSocialDock's icon-only row (below) docks there, so the card's sticky
// position isn't constrained by where the arrow needs to end up.

const DOCK_TOP_THRESHOLD = 80;
const DOCK_NEXT_SECTION = "published-work";
const DOCK_TAP_HIDE_MS = 5000;
function ArrowUpGlyph({
  size = 20
}) {
  return /*#__PURE__*/React.createElement("svg", {
    width: size,
    height: size,
    viewBox: "0 0 24 24",
    fill: "none",
    stroke: "currentColor",
    strokeWidth: "2",
    strokeLinecap: "round",
    strokeLinejoin: "round",
    "aria-hidden": "true",
    style: {
      display: "block"
    }
  }, /*#__PURE__*/React.createElement("line", {
    x1: "12",
    y1: "19",
    x2: "12",
    y2: "5"
  }), /*#__PURE__*/React.createElement("polyline", {
    points: "5 12 12 5 19 12"
  }));
}
function ArrowDownGlyph({
  size = 20
}) {
  return /*#__PURE__*/React.createElement("svg", {
    width: size,
    height: size,
    viewBox: "0 0 24 24",
    fill: "none",
    stroke: "currentColor",
    strokeWidth: "2",
    strokeLinecap: "round",
    strokeLinejoin: "round",
    "aria-hidden": "true",
    style: {
      display: "block"
    }
  }, /*#__PURE__*/React.createElement("line", {
    x1: "12",
    y1: "5",
    x2: "12",
    y2: "19"
  }), /*#__PURE__*/React.createElement("polyline", {
    points: "19 12 12 19 5 12"
  }));
}

// Bare chevron (no stem) for HeroScrollCue — deliberately wider than tall,
// unlike ArrowDownGlyph's square viewBox.
function ChevronDownGlyph({
  width = 56,
  height = 20
}) {
  return /*#__PURE__*/React.createElement("svg", {
    width: width,
    height: height,
    viewBox: "0 0 56 20",
    fill: "none",
    stroke: "currentColor",
    strokeWidth: "3",
    strokeLinecap: "round",
    strokeLinejoin: "round",
    "aria-hidden": "true",
    style: {
      display: "block"
    }
  }, /*#__PURE__*/React.createElement("polyline", {
    points: "4 4 28 16 52 4"
  }));
}
function BottomDock() {
  const dockRef = React.useRef(null);
  // Invisible, zero-height marker whose right edge always lands exactly where
  // every content section's own right edge does — portaled as a real DOM
  // child of #ft-scroll (not position:fixed against the raw viewport) so it
  // shares the same coordinate space every section is laid out in. #ft-scroll
  // has its own scrollbar (see index.html's ::-webkit-scrollbar rule) that
  // carves ~11px+ out of its content-box width; a position:fixed marker
  // ignores that and measures against the full, scrollbar-inclusive viewport,
  // landing this many px further right than sections actually end. Same
  // padding-then-center CSS the sections themselves use (see ProjectGrid.jsx's
  // comment on why that order matters), so this still tracks them instead of
  // duplicating their clamp()/container math as a second, driftable copy.
  // Measured via ResizeObserver into contentRightRef, not every animation
  // frame — it only changes on resize or the scrollbar appearing/disappearing.
  const contentEdgeRef = React.useRef(null);
  const contentRightRef = React.useRef(null);
  const [scrollEl, setScrollEl] = React.useState(null);
  const stateRef = React.useRef({
    hovered: false,
    tapped: false,
    hideTimer: null,
    prevMode: null,
    atTop: true,
    nearCard: false,
    travel: 0,
    x: null,
    y: null,
    anchorX: null,
    anchorY: null,
    prevXMode: null,
    prevYMode: null,
    wroteX: null,
    wroteY: null,
    prevOpen: null,
    prevLabels: null
  });
  React.useEffect(() => {
    setScrollEl(document.querySelector("#ft-scroll"));
  }, []);
  React.useEffect(() => {
    const el = contentEdgeRef.current;
    if (!el) return;
    const update = () => {
      contentRightRef.current = el.getBoundingClientRect().right;
    };
    update();
    const ro = new ResizeObserver(update);
    ro.observe(el);
    // ResizeObserver only fires when the marker's own box size changes —
    // once the content column is pinned at its var(--container) cap, a
    // resize that just re-centers the column (same width, new position:
    // maximizing/restoring the window, moving to a wider monitor) never
    // triggers it, leaving this stale. window resize covers that case.
    window.addEventListener("resize", update);
    return () => {
      ro.disconnect();
      window.removeEventListener("resize", update);
    };
  }, [scrollEl]);
  React.useEffect(() => {
    const dock = dockRef.current;
    const scroller = document.querySelector("#ft-scroll");
    if (!dock) return;
    const st = stateRef.current;
    const isTouch = window.matchMedia && window.matchMedia("(pointer: coarse)").matches;
    const reduced = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (reduced) {
      // CSS max-width/opacity transitions don't progress under reduced-motion —
      // disable them so the inline open/label state below applies instantly.
      Array.from(dock.querySelectorAll(".ft-sdock-item")).forEach(it => {
        it.style.transition = "none";
        const lbl = it.querySelector(".ft-sdock-label");
        if (lbl) lbl.style.transition = "none";
      });
    }
    const margin = () => Math.max(22, Math.min(34, window.innerWidth * 0.03));
    // Extra clearance under the dock on mobile — the plain margin() sits the
    // icon row right against the viewport edge, which overlaps the browser's
    // own bottom chrome / gesture-nav bar on phones.
    const bottomMargin = () => margin() + (window.innerWidth < 768 ? 24 : 0);

    // Event-driven, not a perpetual per-frame poller — frame() used to
    // reschedule itself unconditionally forever from mount, which meant this
    // ran 60x/sec for the entire page lifetime even while nothing was
    // scrolling, resizing, or transitioning (idle-tab cost, and stacked with
    // CardSocialDock/HeroCardSocialDock's own identical loops plus Phaser's
    // render loop right at initial load). Everything this reads (scrollTop,
    // footA/cardEl rects, viewport size) only ever changes in response to a
    // scroll, a resize, or a hover/tap toggle — so scheduleFrame() below is
    // only called from those triggers, plus a self-reschedule while a mode
    // change is still easing (st.travel > 0, ~18 frames), instead of forever.
    let raf = 0;
    // How long to keep re-measuring after an open/close (isOpen/isLabels)
    // flip — the fold itself is a CSS transition on each item's max-width/
    // padding/margin (var(--dur-base), 200ms), which keeps changing the
    // dock's own rendered width for that whole span. Without following it,
    // the right-pinned edge (rectNow.width below) freezes at its pre-flip
    // value while the box keeps growing/shrinking under it. +60ms buffer
    // over the token value so a slow frame doesn't cut the last couple
    // of ticks short.
    let foldSettleUntil = 0;
    const scheduleFrame = () => {
      if (!raf) raf = requestAnimationFrame(frame);
    };
    const frame = () => {
      raf = 0;
      if (document.body.classList.contains("ft-modal-open")) return;
      const vw = window.innerWidth,
        vh = window.innerHeight;
      const footA = document.querySelector("#ft-anchor-footer");
      const dockBot = vh - 56;
      const enterTop = vh * 0.75;

      // ── decide mode ── (card docking lives entirely in CardSocialDock now —
      // this dock only ever floats or docks into the footer, so the arrow
      // stays wherever "float" puts it regardless of the About card's scroll position)
      let mode = "float";
      if (footA) {
        const r = footA.getBoundingClientRect();
        if (r.top >= enterTop && r.top <= dockBot) mode = "footer";
      }

      // ── at-top check — the dock is hidden entirely while at the very top of
      // the page (see the opacity/pointer-events write at the end of this
      // frame, and the top-of-file comment) — computed before isOpen below so
      // isOpen reads this frame's fresh value instead of last frame's stale
      // st.atTop ──
      const scrollTop = scroller ? scroller.scrollTop : window.scrollY;
      const nowAtTop = mode === "float" && scrollTop < DOCK_TOP_THRESHOLD;
      st.atTop = nowAtTop;

      // This dock rests near the viewport's bottom edge while floating — so
      // "near the card" means the card's bottom edge is close to THAT edge
      // specifically, not just anywhere on screen (which would stay true for
      // nearly the whole section while the card sits pinned up near the nav,
      // far from where this dock actually sits).
      const cardEl = document.querySelector("#ft-anchor-card");
      let nearCard = false;
      if (cardEl && mode === "float") {
        const cr = cardEl.getBoundingClientRect();
        const distanceFromBottom = vh - cr.bottom;
        nearCard = distanceFromBottom >= 0 && distanceFromBottom <= 160;
      }
      st.nearCard = nearCard;
      const interaction = st.hovered || st.tapped;
      const isOpen = mode !== "float" || !nearCard && interaction;
      const isLabels = mode === "footer" && vw >= 768;
      dock.classList.toggle("is-open", isOpen);
      dock.classList.toggle("is-labels", isLabels);
      dock.classList.toggle("is-docked", mode !== "float");

      // Drive open/label state via inline styles (authoritative) so it works even
      // when the CSS transition can't run (reduced-motion). The CSS transition,
      // when allowed, simply smooths these inline changes. Only touch the DOM
      // when the state actually flips — this ran unconditionally every frame
      // before, which was most of this loop's per-frame cost since the dock
      // sits in one state for seconds between transitions.
      if (isOpen !== st.prevOpen || isLabels !== st.prevLabels) {
        st.prevOpen = isOpen;
        st.prevLabels = isLabels;
        foldSettleUntil = performance.now() + 260;
        const items = Array.from(dock.querySelectorAll(".ft-sdock-item"));
        items.forEach(it => {
          it.style.maxWidth = isOpen ? "260px" : "0px";
          it.style.opacity = isOpen ? "1" : "0";
          it.style.marginLeft = isOpen ? "8px" : "0px";
          it.style.padding = isOpen ? "0 11px" : "0px";
          it.style.borderColor = isOpen ? "var(--border-strong)" : "transparent";
          const lbl = it.querySelector(".ft-sdock-label");
          if (lbl) {
            lbl.style.maxWidth = isLabels ? "150px" : "0px";
            lbl.style.opacity = isLabels ? "1" : "0";
            lbl.style.marginLeft = isLabels ? "9px" : "0px";
          }
        });
      }

      // Re-measure after the open/close mutation above (rect, from the top of
      // this frame, still reflects last frame's pre-mutation layout) — the
      // anchor recombination below pins the dock's edge against its OWN
      // width/height, so it needs THIS frame's just-applied size, not a
      // stale one. Skipping this re-measure left position compensation a
      // frame behind the item widths' CSS transition on every open/close,
      // which — since that transition eases out fastest at the start —
      // showed up as a brief rightward bulge right as the fold-out began.
      const rectNow = dock.getBoundingClientRect();

      // ── compute an ANCHOR per mode — deliberately never dependent on the
      // dock's OWN live width/height, only on other elements' rects and
      // viewport metrics. This is what the travel-easing below actually
      // eases. Previously each branch computed a final tx/ty like
      // `anchorRight - rect.width` and eased THAT combined value — but
      // rect.width/height genuinely moves every time the fold-out opens or
      // closes, which is exactly what's happening throughout every
      // dock/undock transition. Easing a target that's itself sliding around
      // made the eased value chase a moving point and overshoot toward
      // wherever the width implied (the viewport edge) before both settled —
      // the "bows out toward the edge, then corrects" path. Keeping the
      // anchor width/height-independent and recombining with the dock's
      // CURRENT size fresh every frame (unedged, below) removes that chase
      // entirely — matches what native CSS right/bottom positioning would do. ──
      let anchorX, xMode, anchorY, yMode;
      if (mode === "footer" && footA) {
        const r = footA.getBoundingClientRect();
        anchorX = r.right;
        xMode = "right";
        anchorY = r.top + r.height / 2;
        yMode = "center";
      } else {
        // Right-anchored bottom-right float position — the dock's only other
        // state now (no more hero-centering; see top-of-file comment).
        // Anchored to the content column's own right margin (cached in
        // contentRightRef — see its declaration above) — sitting fully
        // outside it, in the gutter, not just nudged past it. Falls back to
        // hugging the viewport edge (the old behavior) when the gutter's too
        // narrow to fit the dock without pushing it off-screen.
        // Always xMode "right" here (not "left") — the render puts items
        // before the bubble in the row, so a right-pinned anchor is what
        // keeps the bubble itself fixed in place while items unfurl to its
        // left as they open, matching card/footer's behavior. A left-pinned
        // anchor would instead hold the far side (items' start) fixed and
        // let the whole row — bubble included — drift right as it opens,
        // which is both the wrong fold-out direction and, since nothing near
        // the bubble is actually anchored, a much bigger and messier bubble
        // displacement during any open/close transition than a right-pinned
        // anchor produces. 54 is the settled-closed width: .ft-sdock-bubble's
        // 50px plus border, see index.html — added here so the anchor lands
        // at the same resting left edge (contentRight + 8) as before once
        // closed, just measured from the right edge instead of the left.
        const m = margin();
        const contentRight = contentRightRef.current;
        const enoughRoom = contentRight != null && contentRight + 54 + 8 <= vw - m;
        anchorX = contentRight != null && enoughRoom ? contentRight + 8 + 54 : vw - m;
        xMode = "right";
        anchorY = vh - bottomMargin();
        yMode = "bottom";
      }

      // ── mode change kicks off a brief JS-eased "travel"; otherwise track instantly ──
      let k = 1;
      if (mode !== st.prevMode) {
        st.travel = 18;
        st.prevMode = mode;
      }
      if (st.travel > 0) {
        k = 0.24;
        st.travel -= 1;
      }

      // Reseed the eased anchor from the dock's actual current rendered edge
      // — not by reusing the old anchor number under its old meaning —
      // whenever xMode/yMode itself changes (e.g. leaving a left-pinned
      // content-margin anchor straight into a right-pinned card dock).
      // Without this the eased value would jump by however much the two
      // meanings differ, instead of continuing smoothly from where the box
      // visually is. Also covers the very first frame (prevXMode starts
      // null, so this always fires once, seeding from the anchor directly).
      if (xMode !== st.prevXMode) {
        st.anchorX = st.x != null ? xMode === "right" ? st.x + rectNow.width : xMode === "center" ? st.x + rectNow.width / 2 : st.x : anchorX;
        st.prevXMode = xMode;
      }
      if (yMode !== st.prevYMode) {
        st.anchorY = st.y != null ? yMode === "bottom" ? st.y + rectNow.height : yMode === "center" ? st.y + rectNow.height / 2 : st.y : anchorY;
        st.prevYMode = yMode;
      }
      st.anchorX += (anchorX - st.anchorX) * k;
      st.anchorY += (anchorY - st.anchorY) * k;
      // Snap once within sub-pixel range so settled frames stop nudging the
      // value by fractions of a pixel forever (exponential decay never
      // exactly reaches its target), which forced a write every single frame.
      if (Math.abs(anchorX - st.anchorX) < 0.05) st.anchorX = anchorX;
      if (Math.abs(anchorY - st.anchorY) < 0.05) st.anchorY = anchorY;

      // Recombine the (possibly still-traveling) eased anchor with THIS
      // frame's actual live width/height — never eased, so a fold-out
      // opening/closing can only ever shift today's write by its own honest
      // amount, the same instant it happens.
      st.x = xMode === "right" ? st.anchorX - rectNow.width : xMode === "center" ? st.anchorX - rectNow.width / 2 : st.anchorX;
      st.y = yMode === "bottom" ? st.anchorY - rectNow.height : yMode === "center" ? st.anchorY - rectNow.height / 2 : st.anchorY;
      if (st.x !== st.wroteX || st.y !== st.wroteY) {
        dock.style.left = st.x + "px";
        dock.style.top = st.y + "px";
        st.wroteX = st.x;
        st.wroteY = st.y;
      }
      // Hidden entirely while at the very top of the page — HeroScrollCue
      // covers "scroll to next section" there instead (see top-of-file
      // comment). pointer-events off too, so the invisible dock can't be
      // hovered/clicked through while parked at opacity 0.
      const dockOpacity = st.atTop ? "0" : "1";
      if (dock.style.opacity !== dockOpacity) dock.style.opacity = dockOpacity;
      const dockPE = st.atTop ? "none" : "";
      if (dock.style.pointerEvents !== dockPE) dock.style.pointerEvents = dockPE;

      // Keep ticking on our own only while a mode-change is still easing, or
      // the fold-out CSS transition is still running (foldSettleUntil) —
      // otherwise go idle and wait for the next scroll/resize/hover/tap
      // event (listeners below) to schedule the next frame.
      if (st.travel > 0 || performance.now() < foldSettleUntil) scheduleFrame();
    };
    scheduleFrame();

    // hover (desktop) opens float bubble
    const onEnter = () => {
      st.hovered = true;
      scheduleFrame();
    };
    const onLeave = () => {
      st.hovered = false;
      scheduleFrame();
    };
    if (!isTouch) {
      dock.addEventListener("mouseenter", onEnter);
      dock.addEventListener("mouseleave", onLeave);
    }
    // outside tap, or scrolling elsewhere, closes the mobile fold-out early
    // (it also auto-closes on its own via the hide timer started in onBubble)
    const closeTap = () => {
      if (!st.tapped) return;
      st.tapped = false;
      if (st.hideTimer) {
        clearTimeout(st.hideTimer);
        st.hideTimer = null;
      }
      scheduleFrame();
    };
    const onDocClick = e => {
      if (!dock.contains(e.target)) closeTap();
    };
    document.addEventListener("click", onDocClick);
    if (scroller) {
      scroller.addEventListener("scroll", scheduleFrame, {
        passive: true
      });
      scroller.addEventListener("scroll", closeTap, {
        passive: true
      });
    }
    window.addEventListener("resize", scheduleFrame);
    return () => {
      cancelAnimationFrame(raf);
      dock.removeEventListener("mouseenter", onEnter);
      dock.removeEventListener("mouseleave", onLeave);
      document.removeEventListener("click", onDocClick);
      if (scroller) {
        scroller.removeEventListener("scroll", scheduleFrame);
        scroller.removeEventListener("scroll", closeTap);
      }
      window.removeEventListener("resize", scheduleFrame);
      if (st.hideTimer) clearTimeout(st.hideTimer);
    };
  }, []);
  const onBubble = e => {
    const dock = dockRef.current;
    const docked = dock && dock.classList.contains("is-docked");
    const isTouch = window.matchMedia && window.matchMedia("(pointer: coarse)").matches;
    const st = stateRef.current;
    // Mobile float: first tap opens the fold-out so icons are reachable, without
    // scrolling. Second tap (fold-out already open) runs the normal scroll action
    // below and closes it — except near the card (fold-out is suppressed there,
    // see frame()'s nearCard check), which always just scrolls. The dock is
    // never visible while atTop (see frame()'s opacity/pointer-events write),
    // so this handler can only ever fire once scrolled down.
    if (!docked && isTouch && !st.nearCard) {
      if (!st.tapped) {
        e.preventDefault();
        e.stopPropagation();
        st.tapped = true;
        if (st.hideTimer) clearTimeout(st.hideTimer);
        st.hideTimer = setTimeout(() => {
          st.tapped = false;
          st.hideTimer = null;
        }, DOCK_TAP_HIDE_MS);
        return;
      }
      if (st.hideTimer) {
        clearTimeout(st.hideTimer);
        st.hideTimer = null;
      }
      st.tapped = false;
    }
    // Otherwise the bubble scrolls back to the top. Suppress the proximity
    // snap first — this is a deliberate click, it should always win over
    // auto-snap.
    e.preventDefault();
    const scroller = document.querySelector("#ft-scroll");
    const reduced = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (!scroller) return;
    window.FT_SUPPRESS_SNAP_UNTIL = performance.now() + 1500;
    scroller.scrollTo({
      top: 0,
      behavior: reduced ? "auto" : "smooth"
    });
  };
  const iconSize = 20;
  const renderItem = s => /*#__PURE__*/React.createElement("a", {
    key: s.key,
    className: "ft-sdock-item",
    href: s.href,
    target: s.mail ? undefined : "_blank",
    rel: "noreferrer",
    title: s.label,
    "aria-label": s.label
  }, /*#__PURE__*/React.createElement("span", {
    className: "ft-sdock-ic"
  }, /*#__PURE__*/React.createElement(SocialIcon, {
    s: s,
    size: iconSize,
    color: "currentColor"
  })), /*#__PURE__*/React.createElement("span", {
    className: "ft-sdock-label"
  }, s.label));
  return /*#__PURE__*/React.createElement(React.Fragment, null, scrollEl && ReactDOM.createPortal(/*#__PURE__*/React.createElement("div", {
    "aria-hidden": "true",
    style: {
      padding: "0 clamp(20px,5vw,48px)",
      pointerEvents: "none",
      visibility: "hidden",
      height: 0,
      overflow: "hidden"
    }
  }, /*#__PURE__*/React.createElement("div", {
    ref: contentEdgeRef,
    style: {
      maxWidth: "var(--container)",
      margin: "0 auto"
    }
  })), scrollEl), /*#__PURE__*/React.createElement("div", {
    className: "ft-sdock",
    ref: dockRef,
    "aria-label": "Social links",
    style: {
      opacity: 0
    }
  }, /*#__PURE__*/React.createElement("div", {
    className: "ft-sdock-items"
  }, SOCIALS.map(renderItem)), /*#__PURE__*/React.createElement("button", {
    className: "ft-sdock-bubble",
    type: "button",
    onClick: onBubble,
    "aria-label": "Back to top",
    title: "Back to top"
  }, /*#__PURE__*/React.createElement(ArrowUpGlyph, {
    size: iconSize
  }))), /*#__PURE__*/React.createElement(CardSocialDock, null), /*#__PURE__*/React.createElement(HeroCardSocialDock, null), /*#__PURE__*/React.createElement(HeroScrollCue, null));
}

// Icon-only social row permanently positioned at the About card's
// #ft-anchor-card slot — independent of BottomDock's arrow bubble, which
// never travels there. It only ever shows or hides (never "docks" anywhere
// else), triggered by whether the card's bottom edge is currently inside the
// viewport, so it tracks the anchor's live rect directly each frame instead
// of needing BottomDock's multi-target travel-easing.
function CardSocialDock() {
  const dockRef = React.useRef(null);
  const stateRef = React.useRef({
    prevOpen: null
  });
  React.useEffect(() => {
    const dock = dockRef.current;
    if (!dock) return;
    const st = stateRef.current;
    const reduced = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (reduced) {
      Array.from(dock.querySelectorAll(".ft-sdock-item")).forEach(it => {
        it.style.transition = "none";
      });
    }

    // Same expand/collapse sequencing BottomDock used to run for the card
    // anchor — kept here since the card still needs to grow to make room for
    // this row before it fades in (see AboutContact.jsx's DOCK_SLOT comment).
    let cardExpanded = false;
    let cardExpandedAt = 0;
    let cardShrinkAt = 0;
    const EXPAND_MS = 400;
    const SHRINK_DELAY = 300;
    const HYSTERESIS = 30; // px slack once shown, so hovering right at the viewport edge doesn't flicker
    // Static token, not read per frame — the nav sits fixed at the top and
    // this row must never show underneath/behind it.
    const navH = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--nav-h")) || 72;

    // Event-driven and IntersectionObserver-gated, not a perpetual poller —
    // this loop only exists to track the About card, which sits far below
    // the fold. It used to run unconditionally from mount (i.e. from initial
    // page load, reading getBoundingClientRect every frame while the card
    // was nowhere near the viewport) and forever afterward. Now it's fully
    // disarmed (no rAF scheduled at all) until the card comes near the
    // viewport, and once armed, only re-runs in response to an actual
    // scroll/resize, plus self-rescheduling while its own expand/shrink
    // delay (EXPAND_MS/SHRINK_DELAY below) is still counting down.
    let raf = 0;
    const scheduleFrame = () => {
      if (!raf) raf = requestAnimationFrame(frame);
    };
    const frame = () => {
      raf = 0;
      if (document.body.classList.contains("ft-modal-open")) return;
      const now = performance.now();
      const cardA = document.querySelector("#ft-anchor-card");
      const vh = window.innerHeight;

      // Show once the card's bottom edge is inside the viewport (below the
      // nav), hide once it isn't — a wider band once already shown so scroll
      // jitter right at the edges doesn't flicker it on and off.
      let cardBottomVisible = false;
      if (cardA) {
        const r = cardA.getBoundingClientRect();
        cardBottomVisible = cardExpanded ? r.bottom >= navH - HYSTERESIS && r.bottom <= vh + HYSTERESIS : r.bottom >= navH && r.bottom <= vh;
      }
      if (cardA) {
        if (cardBottomVisible && !cardExpanded) {
          cardA.style.height = "42px";
          cardA.style.marginTop = "18px";
          cardExpanded = true;
          cardExpandedAt = now;
          cardShrinkAt = 0;
        }
        if (cardBottomVisible) cardShrinkAt = 0;
        if (!cardBottomVisible && cardExpanded) {
          if (!cardShrinkAt) cardShrinkAt = now;
          if (now - cardShrinkAt > SHRINK_DELAY) {
            cardA.style.height = "0px";
            cardA.style.marginTop = "0px";
            cardExpanded = false;
            cardShrinkAt = 0;
          }
        }
      }
      const isOpen = cardExpanded && cardBottomVisible && now - cardExpandedAt > EXPAND_MS;
      if (isOpen !== st.prevOpen) {
        st.prevOpen = isOpen;
        dock.classList.toggle("is-open", isOpen);
        Array.from(dock.querySelectorAll(".ft-sdock-item")).forEach(it => {
          it.style.maxWidth = isOpen ? "260px" : "0px";
          it.style.opacity = isOpen ? "1" : "0";
          it.style.marginLeft = isOpen ? "8px" : "0px";
          it.style.padding = isOpen ? "0 11px" : "0px";
          it.style.borderColor = isOpen ? "var(--border-strong)" : "transparent";
        });
      }

      // No separate anchor/travel-easing needed — hidden and shown are the
      // same on-screen spot (the card's own edge), just with the items
      // themselves faded/collapsed, so tracking the anchor's live rect
      // directly every frame is already smooth.
      if (cardA) {
        const r = cardA.getBoundingClientRect();
        const rect = dock.getBoundingClientRect();
        dock.style.left = r.right - rect.width + "px";
        dock.style.top = r.top + r.height / 2 - rect.height / 2 + "px";
        if (dock.style.visibility !== "visible") dock.style.visibility = "visible";
      } else if (dock.style.visibility !== "hidden") {
        dock.style.visibility = "hidden";
      }

      // Keep ticking on our own only while the timed expand/shrink delay is
      // still in flight — otherwise go idle until the next scroll/resize.
      const settling = cardShrinkAt !== 0 || cardExpanded && now - cardExpandedAt < EXPAND_MS;
      if (settling) scheduleFrame();
    };
    const scroller = document.querySelector("#ft-scroll") || window;
    let armed = false;
    const arm = () => {
      if (armed) return;
      armed = true;
      scheduleFrame();
      scroller.addEventListener("scroll", scheduleFrame, {
        passive: true
      });
      window.addEventListener("resize", scheduleFrame);
    };
    const disarm = () => {
      if (!armed) return;
      armed = false;
      scroller.removeEventListener("scroll", scheduleFrame);
      window.removeEventListener("resize", scheduleFrame);
      cancelAnimationFrame(raf);
      raf = 0;
      // Retract immediately, same reasoning as ProjectGrid.jsx's own
      // detach() — a stale "open" reading must never survive past the card
      // leaving view, and the loop is no longer running to self-correct it.
      if (st.prevOpen !== false) {
        st.prevOpen = false;
        dock.classList.toggle("is-open", false);
      }
      dock.style.visibility = "hidden";
      cardExpanded = false;
      cardShrinkAt = 0;
    };

    // Generous rootMargin so the loop is already armed and settled by the
    // time the card is actually on screen, not starting cold on the first
    // scroll event after crossing the threshold (same pattern ProjectGrid.jsx
    // uses for its own pin-tracking observer).
    // Observes the whole #about section, NOT #ft-anchor-card itself —
    // frame() above resizes that anchor directly (style.height/marginTop),
    // and observing the exact element you're mutating makes the observer
    // recompute against its own last write every time, adding churn on top
    // of the rAF cost instead of replacing it. #about's own box is governed
    // by the taller experience-timeline column beside it, so the anchor's
    // ~60px grow/shrink doesn't move #about's boundary at all in practice.
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) arm();else disarm();
    }, {
      rootMargin: "300px 0px 300px 0px"
    });
    const sectionEl = document.querySelector("#about");
    if (sectionEl) observer.observe(sectionEl);else scheduleFrame();
    return () => {
      observer.disconnect();
      disarm();
    };
  }, []);
  const renderItem = s => /*#__PURE__*/React.createElement("a", {
    key: s.key,
    className: "ft-sdock-item",
    href: s.href,
    target: s.mail ? undefined : "_blank",
    rel: "noreferrer",
    title: s.label,
    "aria-label": s.label
  }, /*#__PURE__*/React.createElement("span", {
    className: "ft-sdock-ic"
  }, /*#__PURE__*/React.createElement(SocialIcon, {
    s: s,
    size: 20,
    color: "currentColor"
  })));
  return /*#__PURE__*/React.createElement("div", {
    className: "ft-sdock ft-card-sdock",
    ref: dockRef,
    "aria-label": "Social links"
  }, /*#__PURE__*/React.createElement("div", {
    className: "ft-sdock-items"
  }, SOCIALS.map(renderItem)));
}

// Icon-only social row positioned just outside the Hero panel's right edge,
// vertically centered on it — the Hero-card equivalent of CardSocialDock
// above. Unlike the About card, the Hero panel is a fixed-size decorative
// scrim (see Hero.jsx's PANEL_*), not a content card that needs to grow to
// make room for this row — so it shows/hides as one unit via opacity
// (.ft-hero-card-sdock in index.html) instead of per-item revealing, and
// sits outside the panel rather than inside it. Visibility follows only the
// panel's own on-screen presence — nothing tied to the backdrop game's load
// state, so it's there immediately even before HeroGame's canvas fades in.
// Its item BACKGROUND, though, does follow the game's load state — same
// frost gating as the panel itself (Hero.jsx: backdropFilter only once
// gameReady), via the is-frost class below.
function HeroCardSocialDock() {
  const dockRef = React.useRef(null);
  const stateRef = React.useRef({
    prevOpen: null,
    prevFrost: null,
    gameLoaded: false
  });
  React.useEffect(() => {
    const dock = dockRef.current;
    if (!dock) return;
    const st = stateRef.current;
    // Static token, not read per frame — the nav sits fixed at the top and
    // this row must never show underneath/behind it.
    const navH = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--nav-h")) || 72;
    const GAP = 24; // gap between the panel's right edge and the icon column

    // HeroGame.jsx broadcasts this once the Phaser backdrop actually starts
    // its fade-in (and again, false, on teardown) — same event Hero.jsx's
    // own panel frost is gated on.
    // Event-driven and IntersectionObserver-gated — same pattern as
    // CardSocialDock above. This one IS visible at initial load (the hero
    // panel), so the observer arms almost immediately, but once scrolled
    // past hero it now actually stops polling instead of running forever.
    let raf = 0;
    const scheduleFrame = () => {
      if (!raf) raf = requestAnimationFrame(frame);
    };
    const onGameLoaded = e => {
      st.gameLoaded = !!(e.detail && e.detail.loaded);
      scheduleFrame();
    };
    window.addEventListener("ft-hero-game-loaded", onGameLoaded);
    const frame = () => {
      raf = 0;
      if (document.body.classList.contains("ft-modal-open")) return;
      const panel = document.querySelector("#ft-anchor-hero-card");
      const vh = window.innerHeight;
      if (st.gameLoaded !== st.prevFrost) {
        st.prevFrost = st.gameLoaded;
        dock.classList.toggle("is-frost", st.gameLoaded);
      }
      if (panel) {
        const r = panel.getBoundingClientRect();
        const visible = r.bottom > navH && r.top < vh;
        if (visible !== st.prevOpen) {
          st.prevOpen = visible;
          dock.classList.toggle("is-open", visible);
        }
        const rect = dock.getBoundingClientRect();
        dock.style.left = r.right + GAP + "px";
        dock.style.top = r.top + r.height / 2 - rect.height / 2 + "px";
        if (dock.style.visibility !== "visible") dock.style.visibility = "visible";
      } else {
        if (st.prevOpen !== false) {
          st.prevOpen = false;
          dock.classList.toggle("is-open", false);
        }
        if (dock.style.visibility !== "hidden") dock.style.visibility = "hidden";
      }
      // No timed transition here (unlike CardSocialDock) — nothing settles
      // on its own; only a scroll/resize/game-load event needs a re-check.
    };
    const scroller = document.querySelector("#ft-scroll") || window;
    let armed = false;
    const arm = () => {
      if (armed) return;
      armed = true;
      scheduleFrame();
      scroller.addEventListener("scroll", scheduleFrame, {
        passive: true
      });
      window.addEventListener("resize", scheduleFrame);
    };
    const disarm = () => {
      if (!armed) return;
      armed = false;
      scroller.removeEventListener("scroll", scheduleFrame);
      window.removeEventListener("resize", scheduleFrame);
      cancelAnimationFrame(raf);
      raf = 0;
      if (st.prevOpen !== false) {
        st.prevOpen = false;
        dock.classList.toggle("is-open", false);
      }
      dock.style.visibility = "hidden";
    };
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) arm();else disarm();
    }, {
      rootMargin: "300px 0px 300px 0px"
    });
    const panelEl = document.querySelector("#ft-anchor-hero-card");
    if (panelEl) observer.observe(panelEl);else scheduleFrame();
    return () => {
      cancelAnimationFrame(raf);
      window.removeEventListener("ft-hero-game-loaded", onGameLoaded);
      observer.disconnect();
      disarm();
    };
  }, []);
  const renderItem = s => /*#__PURE__*/React.createElement("a", {
    key: s.key,
    className: "ft-sdock-item",
    href: s.href,
    target: s.mail ? undefined : "_blank",
    rel: "noreferrer",
    title: s.label,
    "aria-label": s.label
  }, /*#__PURE__*/React.createElement("span", {
    className: "ft-sdock-ic"
  }, /*#__PURE__*/React.createElement(SocialIcon, {
    s: s,
    size: 32,
    color: "currentColor"
  })));
  return /*#__PURE__*/React.createElement("div", {
    className: "ft-sdock ft-hero-card-sdock",
    ref: dockRef,
    "aria-label": "Social links"
  }, /*#__PURE__*/React.createElement("div", {
    className: "ft-sdock-items"
  }, SOCIALS.map(renderItem)));
}

// Hero-only "scroll to next section" cue — a bare chevron, no button chrome,
// pinned near the bottom of the viewport while at the very top of the page.
// Replaces BottomDock's old always-open hero state (now obsolete — BottomDock
// itself is hidden entirely until scrolled past DOCK_TOP_THRESHOLD, see the
// top-of-file comment). Independent of BottomDock's mode/anchor travel
// machinery — this never moves, it only fades and bobs.
function HeroScrollCue() {
  const [visible, setVisible] = React.useState(true);
  React.useEffect(() => {
    const scroller = document.querySelector("#ft-scroll");
    const check = () => setVisible((scroller ? scroller.scrollTop : window.scrollY) < DOCK_TOP_THRESHOLD);
    check();
    const target = scroller || window;
    target.addEventListener("scroll", check, {
      passive: true
    });
    return () => target.removeEventListener("scroll", check);
  }, []);
  const onClick = e => {
    e.preventDefault();
    const scroller = document.querySelector("#ft-scroll");
    const target = scroller && document.getElementById(DOCK_NEXT_SECTION);
    if (!scroller || !target) return;
    const reduced = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const tr = target.getBoundingClientRect();
    const sr = scroller.getBoundingClientRect();
    const top = tr.top - sr.top + scroller.scrollTop - window.FT_NAV_OFFSET;
    window.FT_SUPPRESS_SNAP_UNTIL = performance.now() + 1500;
    scroller.scrollTo({
      top: Math.max(0, top),
      behavior: reduced ? "auto" : "smooth"
    });
  };
  return /*#__PURE__*/React.createElement("button", {
    type: "button",
    className: "ft-hero-scroll-cue",
    onClick: onClick,
    "aria-label": "Scroll to next section",
    title: "Scroll to next section",
    style: {
      opacity: visible ? 1 : 0,
      pointerEvents: visible ? "auto" : "none"
    }
  }, /*#__PURE__*/React.createElement(ChevronDownGlyph, null));
}
window.BottomDock = BottomDock;
})();
