import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react";
import {
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type MouseEvent as ReactMouseEvent,
  type PointerEvent as ReactPointerEvent,
  type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import {
  createPhotoGalleryModel,
  hasMeaningfulScroll,
  reducePhotoGalleryState,
  type PhotoGalleryEvent as GalleryEvent,
  type PhotoGalleryState,
} from "./model.mjs";
import {
  PHOTO_GALLERY_LAYOUT_TRANSITION,
  PHOTO_GALLERY_STACK_RETURN_DISSOLVE,
  PHOTO_GALLERY_STACK_RETURN_LAYOUT_TRANSITION,
} from "./motion";
import "./PhotoGallery.css";

export type PhotoGalleryPhoto = {
  id: string;
  label?: string;
  previewSrc: string;
  src: string;
  alt: string;
  caption: ReactNode;
  focalPoint?: { x: number; y: number };
};

export type PhotoGalleryNavigationUpdate = {
  mode: "default" | "gallery";
  phase: "opening" | "closing" | "settled";
};

export type PhotoGalleryProps = {
  photos: readonly PhotoGalleryPhoto[];
  previewIds: readonly [string, string, string];
  triggerLabel: string;
  dialogLabel: string;
  navigationTarget?: HTMLElement | null;
  pageContentTarget?: HTMLElement | null;
  onNavigationChange?: (update: PhotoGalleryNavigationUpdate) => void;
  labels?: {
    open?: string;
    close?: string;
    back?: string;
  };
};

const PREVIEW_CLOSE_DELAY_MS = 180;
const PREVIEW_GAP_PX = 2;
const STACK_WIDTH = 150;
const STACK_HEIGHT = 142;
const VIEWPORT_PADDING = 16;
const PHOTO_LAYOUT_TRANSITION = {
  ...PHOTO_GALLERY_LAYOUT_TRANSITION,
  opacity: { duration: 0.14, ease: "easeOut" as const },
} as const;
const STACK_RETURN_LAYOUT_TRANSITION = {
  ...PHOTO_GALLERY_STACK_RETURN_LAYOUT_TRANSITION,
  opacity: { duration: 0.14, ease: "easeOut" as const },
} as const;
const GALLERY_CARD_ROTATIONS = [-3, 4, 2, -2, -4, 3] as const;
const preloadedImages = new Set<string>();

type StackPosition = {
  left: number;
  top: number;
  side: "above" | "below";
};

function clamp(value: number, min: number, max: number) {
  return Math.min(Math.max(value, min), max);
}

function preloadImage(src: string) {
  if (typeof Image === "undefined" || preloadedImages.has(src)) return;

  preloadedImages.add(src);
  const image = new Image();
  image.decoding = "async";
  image.src = src;
}

function useDecodedImage(src: string) {
  const [isDecoded, setIsDecoded] = useState(false);

  useEffect(() => {
    let cancelled = false;
    let decodeFailed = false;
    let hasLoaded = false;
    setIsDecoded(false);

    if (typeof Image === "undefined") return;

    const image = new Image();
    const markDecoded = () => {
      if (!cancelled) setIsDecoded(true);
    };
    const handleLoad = () => {
      hasLoaded = true;
      if (typeof image.decode !== "function" || decodeFailed) markDecoded();
    };

    image.decoding = "async";
    image.addEventListener("load", handleLoad);
    image.src = src;

    if (typeof image.decode === "function") {
      image.decode().then(markDecoded).catch(() => {
        decodeFailed = true;
        if (hasLoaded || (image.complete && image.naturalWidth > 0)) markDecoded();
      });
    }

    return () => {
      cancelled = true;
      image.removeEventListener("load", handleLoad);
    };
  }, [src]);

  return isDecoded;
}

function PhotoStack({
  photos,
  previewPhotos,
  layoutScope,
  stackId,
  actionLabel,
  onActivate,
  onPointerEnter,
  onPointerLeave,
  onFocus,
  onBlur,
  onFan,
  onUnfan,
  onGalleryIntent,
  isFanned,
  isPinned,
  triggerRef,
  stackRef,
  reduceMotion,
  isReturning = false,
  isParked = false,
  onReturnComplete,
}: {
  photos: readonly PhotoGalleryPhoto[];
  previewPhotos: readonly PhotoGalleryPhoto[];
  layoutScope: string;
  stackId?: string;
  actionLabel: string;
  onActivate: () => void;
  onPointerEnter: () => void;
  onPointerLeave: () => void;
  onFocus: () => void;
  onBlur: () => void;
  onFan: () => void;
  onUnfan: () => void;
  onGalleryIntent: () => void;
  isFanned: boolean;
  isPinned: boolean;
  triggerRef: React.RefObject<HTMLButtonElement | null>;
  stackRef: React.RefObject<HTMLButtonElement | null>;
  reduceMotion: boolean;
  isReturning?: boolean;
  isParked?: boolean;
  onReturnComplete?: () => void;
}) {
  const stackPhotos = photos
    .map((photo: PhotoGalleryPhoto, galleryIndex: number) => ({
      photo,
      galleryIndex,
      previewIndex: previewPhotos.findIndex(({ id }) => id === photo.id),
    }))
    .sort((a, b) => {
      const aOrder = a.previewIndex >= 0 ? a.previewIndex : previewPhotos.length + a.galleryIndex;
      const bOrder = b.previewIndex >= 0 ? b.previewIndex : previewPhotos.length + b.galleryIndex;
      return aOrder - bOrder;
    });

  function handlePointerEnter() {
    onGalleryIntent();
    onPointerEnter();
  }

  function handlePhotoPointerEnter(event: ReactPointerEvent<HTMLSpanElement>) {
    if (event.pointerType === "mouse") onFan();
  }

  function handlePhotoPointerLeave(event: ReactPointerEvent<HTMLSpanElement>) {
    const nextTarget = event.relatedTarget;
    if (!(nextTarget instanceof Element)) return;
    if (nextTarget.closest(".photo-stack-gallery__stack__card")) return;
    if (
      stackRef.current?.contains(nextTarget) ||
      triggerRef.current?.contains(nextTarget)
    ) {
      onUnfan();
    }
  }

  function handlePointerLeave() {
    if (isPinned) onUnfan();
    onPointerLeave();
  }

  function handleFocus() {
    onGalleryIntent();
    onFan();
    onFocus();
  }

  function handleBlur() {
    if (isPinned) onUnfan();
    onBlur();
  }

  return (
    <motion.button
      ref={stackRef}
      id={isParked || isReturning ? undefined : stackId}
      type="button"
      className="photo-stack-gallery__stack"
      data-fanned={isFanned}
      data-returning={isReturning}
      aria-label={actionLabel}
      aria-hidden={isReturning || isParked || undefined}
      disabled={isReturning || isParked}
      onClick={onActivate}
      onPointerDown={onGalleryIntent}
      onPointerEnter={handlePointerEnter}
      onPointerLeave={handlePointerLeave}
      onFocus={handleFocus}
      onBlur={handleBlur}
      whileTap={reduceMotion ? undefined : { scale: 0.97 }}
    >
      {stackPhotos.map(({ photo, previewIndex }) => {
        const shouldRenderPreview = previewIndex >= 0 || isParked || isReturning;

        return (
          <motion.span
            key={photo.id}
            layoutId={isParked ? undefined : `${layoutScope}-photo-${photo.id}`}
            layoutCrossfade={false}
            className="photo-stack-gallery__stack__slot"
            data-concealed={previewIndex < 0}
            initial={false}
            animate={{ opacity: previewIndex < 0 ? 0 : 1 }}
            transition={
              isReturning ? STACK_RETURN_LAYOUT_TRANSITION : PHOTO_LAYOUT_TRANSITION
            }
            onLayoutAnimationComplete={
              isReturning && photo.id === previewPhotos[1]?.id
                ? onReturnComplete
                : undefined
            }
          >
            <span
              className="photo-stack-gallery__stack__card"
              onPointerEnter={handlePhotoPointerEnter}
              onPointerLeave={handlePhotoPointerLeave}
            >
              {shouldRenderPreview ? (
                <img
                  src={photo.previewSrc}
                  alt=""
                  width={480}
                  height={640}
                  decoding="async"
                  draggable={false}
                />
              ) : null}
            </span>
          </motion.span>
        );
      })}
    </motion.button>
  );
}

function GalleryNavigationControl({
  buttonRef,
  navigationId,
  mode,
  closeLabel,
  backLabel,
  onActivate,
  disabled = false,
}: {
  buttonRef: React.RefObject<HTMLButtonElement | null>;
  navigationId: string;
  mode: "close" | "back";
  closeLabel: string;
  backLabel: string;
  onActivate: () => void;
  disabled?: boolean;
}) {
  return (
    <button
      ref={buttonRef}
      id={navigationId}
      type="button"
      className="photo-stack-gallery__gallery__close"
      data-mode={mode}
      aria-label={mode === "back" ? backLabel : closeLabel}
      onClick={onActivate}
      disabled={disabled}
    >
      <svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true">
        <path d="M5 5L15 15M15 5L5 15" />
      </svg>
    </button>
  );
}

function GalleryOverlay({
  photos,
  layoutScope,
  reduceMotion,
  onSelect,
  onPhotoIntent,
  selectedPhotoId,
  isDetailContext,
  returnPhotoId,
  onGalleryReturnComplete,
  isStackReturning,
}: {
  photos: readonly PhotoGalleryPhoto[];
  layoutScope: string;
  reduceMotion: boolean;
  onSelect: (photoId: string) => void;
  onPhotoIntent: (src: string) => void;
  selectedPhotoId?: string;
  isDetailContext: boolean;
  returnPhotoId?: string;
  onGalleryReturnComplete?: () => void;
  isStackReturning: boolean;
}) {
  return (
    <motion.div
      className="photo-stack-gallery__gallery"
      data-returning={Boolean(returnPhotoId)}
      data-stack-returning={isStackReturning}
    >
      {!isStackReturning ? (
        <div className="photo-stack-gallery__gallery__stage">
          {photos.map((photo: PhotoGalleryPhoto, index: number) => {
            const isSelectedPhoto = selectedPhotoId === photo.id;
            const isContextPhoto = isDetailContext && !isSelectedPhoto;
            const galleryOpacity = isContextPhoto ? 0.16 : 1;

            return (
              <motion.button
                key={photo.id}
                type="button"
                className="photo-stack-gallery__gallery__slot"
                data-selected={isSelectedPhoto || undefined}
                aria-label={`View ${photo.label ?? photo.alt}`}
                layoutId={`${layoutScope}-photo-${photo.id}`}
                layoutCrossfade={false}
                style={{
                  "--card-rotation": `${GALLERY_CARD_ROTATIONS[index]}deg`,
                } as CSSProperties}
                onClick={() => onSelect(photo.id)}
                onPointerEnter={() => onPhotoIntent(photo.src)}
                onFocus={() => onPhotoIntent(photo.src)}
                onPointerDown={() => onPhotoIntent(photo.src)}
                disabled={isDetailContext}
                initial={false}
                animate={{
                  opacity: galleryOpacity,
                  filter: isContextPhoto ? "blur(8px)" : "blur(0px)",
                }}
                transition={{
                  ...PHOTO_LAYOUT_TRANSITION,
                  opacity: { duration: reduceMotion ? 0 : 0.14, ease: "easeOut" },
                  filter: { duration: reduceMotion ? 0 : 0.14, ease: "easeOut" },
                }}
                whileTap={reduceMotion ? undefined : { scale: 0.98 }}
                onLayoutAnimationComplete={
                  returnPhotoId === photo.id ? onGalleryReturnComplete : undefined
                }
              >
                <motion.span
                  className="photo-stack-gallery__gallery__card"
                  initial={false}
                  animate={{ opacity: 1 }}
                  transition={{
                    duration: reduceMotion ? 0 : 0.14,
                    ease: "easeOut",
                  }}
                >
                  <img
                    src={photo.previewSrc}
                    alt={photo.alt}
                    width={480}
                    height={640}
                    decoding="async"
                    draggable={false}
                  />
                </motion.span>
              </motion.button>
            );
          })}
        </div>
      ) : null}
    </motion.div>
  );
}

function PhotoDetail({
  photo: selectedPhoto,
  layoutScope,
  rotation,
  reduceMotion,
}: {
  photo: PhotoGalleryPhoto;
  layoutScope: string;
  rotation: number;
  reduceMotion: boolean;
}) {
  const isFullImageDecoded = useDecodedImage(selectedPhoto.src);
  return (
    <motion.div className="photo-stack-gallery__detail" variants={{ exit: {} }} exit="exit">
      <div className="photo-stack-gallery__detail__content">
        <motion.div
          key={selectedPhoto.id}
          className="photo-stack-gallery__detail__media"
          layoutId={`${layoutScope}-photo-${selectedPhoto.id}`}
          layoutCrossfade={false}
          initial={reduceMotion ? false : { rotate: rotation }}
          animate={{ rotate: 0 }}
          exit={reduceMotion ? undefined : { rotate: rotation }}
          transition={PHOTO_LAYOUT_TRANSITION}
        >
          <img
            className="photo-stack-gallery__detail__preview-image"
            src={selectedPhoto.previewSrc}
            alt={selectedPhoto.alt}
            width={480}
            height={640}
            decoding="async"
            draggable={false}
          />
          {isFullImageDecoded ? (
            <motion.img
              className="photo-stack-gallery__detail__full-image"
              src={selectedPhoto.src}
              alt=""
              decoding="async"
              draggable={false}
              style={{
                objectPosition: `${selectedPhoto.focalPoint?.x ?? 50}% ${selectedPhoto.focalPoint?.y ?? 50}%`,
              }}
              initial={reduceMotion ? false : { opacity: 0 }}
              animate={{ opacity: 1 }}
              variants={{
                exit: {
                  opacity: 0,
                  transition: {
                    duration: reduceMotion ? 0 : 0.08,
                    ease: "easeOut",
                  },
                },
              }}
              transition={{
                duration: reduceMotion ? 0 : 0.14,
                ease: "easeOut",
              }}
            />
          ) : null}
        </motion.div>
        <motion.div
          className="photo-stack-gallery__detail__meta"
          initial={reduceMotion ? false : { opacity: 0, y: 8 }}
          animate={{ opacity: 1, y: 0 }}
          exit={{
            opacity: 0,
            y: 4,
            transition: { duration: reduceMotion ? 0 : 0.1, ease: "easeOut" },
          }}
          transition={{
            duration: reduceMotion ? 0 : 0.22,
            delay: reduceMotion ? 0 : 0.12,
          }}
        >
          <div className="photo-stack-gallery__detail__copy">
            <p className="photo-stack-gallery__detail__caption">
              {selectedPhoto.caption}
            </p>
          </div>
        </motion.div>
      </div>
    </motion.div>
  );
}

export default function PhotoGallery({
  photos: photoInput,
  previewIds,
  triggerLabel,
  dialogLabel,
  navigationTarget,
  pageContentTarget,
  onNavigationChange,
  labels,
}: PhotoGalleryProps) {
  const generatedId = useId().replace(/:/g, "");
  const layoutScope = `photo-stack-gallery-${generatedId}`;
  const stackId = `${layoutScope}-stack`;
  const dialogId = `${layoutScope}-dialog`;
  const navigationId = `${layoutScope}-navigation`;
  const model = useMemo(
    () => createPhotoGalleryModel(photoInput, previewIds),
    [photoInput, previewIds]
  );
  const { photos, previewPhotos, galleryPhotos, photoIds } = model;
  const openLabel = labels?.open ?? `Open ${dialogLabel}`;
  const closeLabel = labels?.close ?? `Close ${dialogLabel}`;
  const backLabel = labels?.back ?? `Back to ${dialogLabel}`;
  const [state, setState] = useState<PhotoGalleryState>({ view: "rest" });
  const [isMounted, setIsMounted] = useState(false);
  const [isFanned, setIsFanned] = useState(false);
  const [isStackReturnSettled, setIsStackReturnSettled] = useState(false);
  const [position, setPosition] = useState<StackPosition>({ left: 0, top: 0, side: "above" });
  const triggerWrapRef = useRef<HTMLSpanElement>(null);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const stackRef = useRef<HTMLButtonElement>(null);
  const dialogRef = useRef<HTMLDivElement>(null);
  const galleryCloseRef = useRef<HTMLButtonElement>(null);
  const previousNavigationModeRef = useRef<"default" | "gallery">("default");
  const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const suppressFocusPreviewRef = useRef(false);
  const focusStackOnPinRef = useRef(false);
  const reduceMotion = useReducedMotion() ?? false;

  const dispatch = useCallback(
    (event: GalleryEvent) => {
      setState((current) => reducePhotoGalleryState(current, event, photoIds));
    },
    [photoIds]
  );

  const preloadGalleryPreviews = useCallback(() => {
    for (const photo of photos) preloadImage(photo.previewSrc);
  }, [photos]);

  const preloadFullPhoto = useCallback((src: string) => {
    preloadImage(src);
  }, []);

  const clearCloseTimer = useCallback(() => {
    if (!closeTimerRef.current) return;
    clearTimeout(closeTimerRef.current);
    closeTimerRef.current = null;
  }, []);

  const restoreTriggerFocus = useCallback(() => {
    suppressFocusPreviewRef.current = true;
    triggerRef.current?.focus();
    requestAnimationFrame(() => {
      suppressFocusPreviewRef.current = false;
    });
  }, []);

  const updatePosition = useCallback(() => {
    const trigger = triggerRef.current;
    if (!trigger) return;
    const rect = trigger.getBoundingClientRect();
    const roomAbove = rect.top >= STACK_HEIGHT + PREVIEW_GAP_PX + VIEWPORT_PADDING;
    const side = roomAbove ? "above" : "below";
    const left = clamp(
      rect.left + rect.width / 2 - STACK_WIDTH / 2,
      VIEWPORT_PADDING,
      window.innerWidth - STACK_WIDTH - VIEWPORT_PADDING
    );
    const top = roomAbove
      ? rect.top - STACK_HEIGHT - PREVIEW_GAP_PX
      : rect.bottom + PREVIEW_GAP_PX;
    setPosition({ left: Math.round(left), top: Math.round(top), side });
  }, []);

  const openTemporaryPreview = useCallback(() => {
    clearCloseTimer();
    updatePosition();
    dispatch({ type: "HOVER_START" });
  }, [clearCloseTimer, dispatch, updatePosition]);

  const scheduleTemporaryClose = useCallback(() => {
    clearCloseTimer();
    closeTimerRef.current = setTimeout(() => {
      dispatch({ type: "HOVER_END" });
      closeTimerRef.current = null;
    }, PREVIEW_CLOSE_DELAY_MS);
  }, [clearCloseTimer, dispatch]);

  const closeTemporaryPreview = useCallback(() => {
    clearCloseTimer();
    dispatch({ type: "HOVER_END" });
  }, [clearCloseTimer, dispatch]);

  useEffect(() => {
    setIsMounted(true);
    return clearCloseTimer;
  }, [clearCloseTimer]);

  useEffect(() => {
    if (state.view !== "stack-return") setIsStackReturnSettled(false);
  }, [state.view]);

  useEffect(() => {
    if (state.view === "preview" && state.pinned) preloadGalleryPreviews();
  }, [preloadGalleryPreviews, state]);

  useEffect(() => {
    if (!reduceMotion) return;
    if (state.view === "gallery-return") {
      dispatch({ type: "GALLERY_RETURN_COMPLETE" });
    } else if (state.view === "stack-return") {
      dispatch({ type: "STACK_RETURN_COMPLETE" });
    }
  }, [dispatch, reduceMotion, state.view]);

  useEffect(() => {
    if (state.view !== "preview") return;
    const scrollOrigin = { x: window.scrollX, y: window.scrollY };

    function handleOutsidePress(event: PointerEvent) {
      const target = event.target as Node;
      if (triggerWrapRef.current?.contains(target) || stackRef.current?.contains(target)) return;
      dispatch({ type: "CLOSE" });
    }

    function handleKeyDown(event: KeyboardEvent) {
      if (event.key === "Escape") {
        event.preventDefault();
        dispatch({ type: "CLOSE" });
        restoreTriggerFocus();
      }
    }

    function handleViewportChange() {
      updatePosition();
    }

    function handlePreviewScroll() {
      const currentScroll = { x: window.scrollX, y: window.scrollY };
      if (!hasMeaningfulScroll(scrollOrigin, currentScroll)) return;
      clearCloseTimer();
      dispatch({ type: "CLOSE" });
    }

    document.addEventListener("pointerdown", handleOutsidePress);
    document.addEventListener("keydown", handleKeyDown);
    window.addEventListener("resize", handleViewportChange);
    window.addEventListener("scroll", handlePreviewScroll, { capture: true, passive: true });
    return () => {
      document.removeEventListener("pointerdown", handleOutsidePress);
      document.removeEventListener("keydown", handleKeyDown);
      window.removeEventListener("resize", handleViewportChange);
      window.removeEventListener("scroll", handlePreviewScroll, true);
    };
  }, [clearCloseTimer, dispatch, restoreTriggerFocus, state.view, updatePosition]);

  const isFullScreen =
    state.view === "gallery" ||
    state.view === "detail" ||
    state.view === "gallery-return" ||
    state.view === "stack-return";

  useEffect(() => {
    if (!isFullScreen) return;

    const scrollY = window.scrollY;
    const pageMain =
      pageContentTarget === undefined
        ? (document.querySelector("main") as HTMLElement | null)
        : pageContentTarget;
    const previousMainInert = pageMain?.inert ?? false;
    const previousBodyPosition = document.body.style.position;
    const previousBodyTop = document.body.style.top;
    const previousBodyWidth = document.body.style.width;

    document.body.style.position = "fixed";
    document.body.style.top = `-${scrollY}px`;
    document.body.style.width = "100%";
    if (pageMain) pageMain.inert = true;

    requestAnimationFrame(() => galleryCloseRef.current?.focus());

    return () => {
      document.body.style.position = previousBodyPosition;
      document.body.style.top = previousBodyTop;
      document.body.style.width = previousBodyWidth;
      if (pageMain) pageMain.inert = previousMainInert;
      window.scrollTo(0, scrollY);
      restoreTriggerFocus();
    };
  }, [isFullScreen, pageContentTarget, restoreTriggerFocus]);

  useLayoutEffect(() => {
    const mode =
      isFullScreen && state.view !== "stack-return" ? "gallery" : "default";
    const previousMode = previousNavigationModeRef.current;
    const phase =
      state.view === "stack-return"
        ? "closing"
        : mode === "gallery" && previousMode !== "gallery"
          ? "opening"
          : "settled";

    onNavigationChange?.({ mode, phase });
    previousNavigationModeRef.current = mode;
  }, [isFullScreen, onNavigationChange, state.view]);

  useEffect(() => {
    if (!isFullScreen) return;
    if (state.view === "stack-return") return;
    requestAnimationFrame(() => galleryCloseRef.current?.focus());
  }, [isFullScreen, state.view]);

  useEffect(() => {
    if (!isFullScreen) return;

    function handleFullScreenKeyDown(event: KeyboardEvent) {
      if (event.key === "Escape") {
        event.preventDefault();
        dispatch({ type: "BACK" });
        return;
      }

      if (event.key === "Tab") {
        const dialogControls = Array.from(
          dialogRef.current?.querySelectorAll<HTMLElement>(
            'button, [href], [tabindex]:not([tabindex="-1"])'
          ) ?? []
        ).filter((control) => {
          if (control instanceof HTMLButtonElement && control.disabled) return false;
          return !control.closest("[inert]");
        });
        const menuControl = galleryCloseRef.current;
        const controls = [
          ...dialogControls,
          ...(menuControl && !menuControl.disabled ? [menuControl] : []),
        ];
        if (!controls.length) return;
        const currentIndex = controls.indexOf(document.activeElement as HTMLElement);
        if (currentIndex < 0) return;
        const direction = event.shiftKey ? -1 : 1;
        const nextIndex = (currentIndex + direction + controls.length) % controls.length;
        event.preventDefault();
        controls[nextIndex].focus();
      }
    }

    document.addEventListener("keydown", handleFullScreenKeyDown);
    return () => document.removeEventListener("keydown", handleFullScreenKeyDown);
  }, [dispatch, isFullScreen, state.view]);

  function handleTriggerPointerEnter(event: ReactPointerEvent<HTMLButtonElement>) {
    if (event.pointerType === "mouse") {
      setIsFanned(false);
      openTemporaryPreview();
    }
  }

  function handleTriggerClick(event: ReactMouseEvent<HTMLButtonElement>) {
    clearCloseTimer();
    updatePosition();
    focusStackOnPinRef.current =
      event.detail === 0 &&
      (state.view === "rest" ||
        (state.view === "preview" && !state.pinned));
    dispatch({ type: "TRIGGER_ACTIVATE" });
  }

  function handleTriggerFocus() {
    if (suppressFocusPreviewRef.current) return;
    openTemporaryPreview();
  }

  useLayoutEffect(() => {
    if (!focusStackOnPinRef.current) return;
    if (state.view !== "preview" || !state.pinned) return;

    focusStackOnPinRef.current = false;
    clearCloseTimer();
    stackRef.current?.focus();
  }, [clearCloseTimer, state]);

  const positionStyle = {
    "--photo-stack-gallery-stack-left": `${position.left}px`,
    "--photo-stack-gallery-stack-top": `${position.top}px`,
  } as CSSProperties;

  const isStackReturning = state.view === "stack-return";
  const stackReturnOpacityTransition = reduceMotion
    ? { duration: 0 }
    : PHOTO_GALLERY_STACK_RETURN_DISSOLVE;
  const isCompactStackParked = isFullScreen && !isStackReturning;
  const showCompactStack =
    state.view === "preview" || (isFullScreen && !isStackReturnSettled);
  const resolvedNavigationTarget = isMounted ? navigationTarget : null;

  const preview = (
    <AnimatePresence
      onExitComplete={() => {
        setIsFanned(false);
        if (state.view === "stack-return") {
          dispatch({ type: "STACK_RETURN_COMPLETE" });
        }
      }}
    >
      {showCompactStack ? (
        <motion.span
          key={`${layoutScope}-compact-preview`}
          className={`photo-stack-gallery__stack-position photo-stack-gallery__stack-position--${position.side}`}
          data-returning={isStackReturning}
          data-parked={isCompactStackParked}
          style={positionStyle}
          initial={
            reduceMotion || isStackReturning
              ? false
              : { opacity: 0, scale: 0.96, filter: "blur(4px)" }
          }
          animate={{
            opacity: isStackReturning ? 0 : 1,
            scale: 1,
            filter: "blur(0px)",
          }}
          exit={
            reduceMotion || isStackReturning
                ? { opacity: 0, transition: { duration: 0 } }
              : {
                  opacity: 0,
                  scale: 0.96,
                  filter: "blur(4px)",
                  transition: {
                    duration: 0.18,
                    ease: [0.25, 0.46, 0.45, 0.94],
                  },
                }
          }
          transition={{
            duration: reduceMotion ? 0 : 0.22,
            ease: [0.25, 0.46, 0.45, 0.94],
            opacity: isStackReturning
              ? stackReturnOpacityTransition
              : {
                  duration: reduceMotion ? 0 : 0.22,
                  ease: [0.25, 0.46, 0.45, 0.94],
                },
          }}
        >
          <PhotoStack
            photos={galleryPhotos}
            previewPhotos={previewPhotos}
            layoutScope={layoutScope}
            stackId={stackId}
            actionLabel={openLabel}
            stackRef={stackRef}
            triggerRef={triggerRef}
            reduceMotion={reduceMotion}
            isFanned={isFanned}
            isPinned={state.view === "preview" && state.pinned}
            isReturning={isStackReturning}
            isParked={isCompactStackParked}
            onReturnComplete={() => setIsStackReturnSettled(true)}
            onFan={() => setIsFanned(true)}
            onUnfan={() => setIsFanned(false)}
            onGalleryIntent={preloadGalleryPreviews}
            onActivate={() => dispatch({ type: "STACK_ACTIVATE" })}
            onPointerEnter={clearCloseTimer}
            onPointerLeave={closeTemporaryPreview}
            onFocus={clearCloseTimer}
            onBlur={closeTemporaryPreview}
          />
        </motion.span>
      ) : null}
    </AnimatePresence>
  );

  const fullScreenExperience = isFullScreen ? (
    <div
      ref={dialogRef}
      id={dialogId}
      className="photo-stack-gallery__dialog"
      data-photo-stack-gallery-dialog
      role="dialog"
      aria-modal="true"
      aria-label={dialogLabel}
      aria-owns={navigationId}
    >
      <AnimatePresence>
        {isFullScreen ? (
          <motion.div
            key="photo-stack-gallery__gallery-surface"
            className="photo-stack-gallery__gallery__surface"
            initial={reduceMotion ? false : { opacity: 0 }}
            animate={{ opacity: state.view === "stack-return" ? 0 : 1 }}
            exit={{ opacity: 0 }}
            transition={{
              duration: reduceMotion ? 0 : state.view === "stack-return" ? 0.18 : 0.22,
              ease: "easeOut",
            }}
          />
        ) : null}
      </AnimatePresence>
      <AnimatePresence mode="sync">
        {state.view === "gallery" ||
        state.view === "detail" ||
        state.view === "gallery-return" ||
        state.view === "stack-return" ? (
          <GalleryOverlay
            photos={galleryPhotos}
            layoutScope={layoutScope}
            key="photo-stack-gallery__gallery"
            reduceMotion={reduceMotion}
            selectedPhotoId={
              state.view === "detail" || state.view === "gallery-return"
                ? state.photoId
                : undefined
            }
            isDetailContext={state.view === "detail"}
            returnPhotoId={state.view === "gallery-return" ? state.photoId : undefined}
            onGalleryReturnComplete={() =>
              dispatch({ type: "GALLERY_RETURN_COMPLETE" })
            }
            isStackReturning={state.view === "stack-return"}
            onPhotoIntent={preloadFullPhoto}
            onSelect={(photoId) => dispatch({ type: "SELECT_PHOTO", photoId })}
          />
        ) : null}
      </AnimatePresence>
      <AnimatePresence mode="sync">
        {state.view === "detail" ? (
          <PhotoDetail
            key="photo-stack-gallery__detail"
            photo={photos.find(({ id }) => id === state.photoId) ?? photos[0]}
            layoutScope={layoutScope}
            rotation={
              GALLERY_CARD_ROTATIONS[
                Math.max(0, galleryPhotos.findIndex(({ id }) => id === state.photoId))
              ]
            }
            reduceMotion={reduceMotion}
          />
        ) : null}
      </AnimatePresence>
    </div>
  ) : null;

  return (
    <LayoutGroup id={layoutScope}>
      <span ref={triggerWrapRef} className="photo-stack-gallery">
        <button
          ref={triggerRef}
          type="button"
          className="inline-trigger photo-stack-gallery__trigger"
          data-decoration="wave"
          aria-expanded={state.view !== "rest"}
          aria-controls={dialogId}
          onPointerEnter={handleTriggerPointerEnter}
          onPointerLeave={scheduleTemporaryClose}
          onFocus={handleTriggerFocus}
          onBlur={scheduleTemporaryClose}
          onClick={handleTriggerClick}
        >
          {triggerLabel}
        </button>
        {isMounted ? createPortal(preview, document.body) : null}
        {isMounted ? createPortal(fullScreenExperience, document.body) : null}
        {isMounted && isFullScreen ? (
          resolvedNavigationTarget ? (
            createPortal(
              <GalleryNavigationControl
                buttonRef={galleryCloseRef}
                navigationId={navigationId}
                mode={state.view === "detail" ? "back" : "close"}
                closeLabel={closeLabel}
                backLabel={backLabel}
                onActivate={() =>
                  dispatch({ type: state.view === "detail" ? "BACK" : "CLOSE" })
                }
                disabled={state.view === "stack-return"}
              />,
              resolvedNavigationTarget
            )
          ) : (
            createPortal(
              <div className="photo-stack-gallery__navigation-fallback">
                <GalleryNavigationControl
                  buttonRef={galleryCloseRef}
                  navigationId={navigationId}
                  mode={state.view === "detail" ? "back" : "close"}
                  closeLabel={closeLabel}
                  backLabel={backLabel}
                  onActivate={() =>
                    dispatch({ type: state.view === "detail" ? "BACK" : "CLOSE" })
                  }
                  disabled={state.view === "stack-return"}
                />
              </div>,
              document.body
            )
          )
        ) : null}
      </span>
    </LayoutGroup>
  );
}
.photo-stack-gallery {
  display: inline;
  color: inherit;
}

.photo-stack-gallery__trigger {
  position: relative;
}

.photo-stack-gallery__catalog {
  display: grid;
  justify-items: center;
  gap: 4px;
  width: 150px;
  color: inherit;
}

.photo-stack-gallery__catalog__stack {
  display: block;
  width: 150px;
  height: 142px;
}

.photo-stack-gallery__catalog__label {
  display: inline-block;
  font-size: 1rem;
  line-height: 1.25;
  white-space: nowrap;
}

/* Text decorations can paint outside an inline control's box. This invisible
   hit slop keeps the wave and its surrounding line area inside the trigger. */
.photo-stack-gallery__trigger::before {
  content: "";
  position: absolute;
  inset: -8px -4px;
}

.photo-stack-gallery__stack-position {
  position: fixed;
  z-index: 80;
  left: var(--photo-stack-gallery-stack-left);
  top: var(--photo-stack-gallery-stack-top);
  display: block;
  width: 150px;
  height: 142px;
  transform-origin: center bottom;
}

.photo-stack-gallery__stack-position[data-returning="true"] {
  z-index: 92;
  pointer-events: none;
}

.photo-stack-gallery__stack-position[data-parked="true"] {
  pointer-events: none;
}

.photo-stack-gallery__stack {
  appearance: none;
  position: relative;
  display: block;
  width: 100%;
  height: 100%;
  border: 0;
  padding: 0;
  background: transparent;
  cursor: pointer;
  transform-origin: center bottom;
}

/* The compact stack is portalled away from its inline trigger. Extend its
   pointer hit area through the placement gap so moving toward the photos never
   crosses page-owned pixels and accidentally starts the close timer. */
.photo-stack-gallery__stack::after {
  content: "";
  position: absolute;
  right: 0;
  bottom: -12px;
  left: 0;
  height: 12px;
}

.photo-stack-gallery__stack:focus-visible {
  outline: 2px solid color-mix(in srgb, var(--foreground) 32%, transparent);
  outline-offset: 6px;
  border-radius: 24px;
}

.photo-stack-gallery__stack__slot {
  position: absolute;
  inset: 28px auto auto 38px;
  display: block;
  width: 74px;
  aspect-ratio: 3 / 4;
}

.photo-stack-gallery__stack__slot[data-concealed="true"] {
  z-index: 0;
  opacity: 0;
  pointer-events: none;
}

.photo-stack-gallery__stack__card {
  position: relative;
  display: block;
  width: 100%;
  height: 100%;
  overflow: clip;
  border-radius: 12px;
  background: var(--hover-bg, rgba(127, 127, 127, 0.12));
  box-shadow: var(--elevated-media-shadow, 0 8px 24px rgba(0, 0, 0, 0.16));
  transition: transform 300ms cubic-bezier(0.22, 1, 0.36, 1);
  will-change: transform;
}

.photo-stack-gallery__stack__card::after {
  content: "";
  position: absolute;
  inset: 0;
  border: 2px solid rgba(255, 255, 255, 0.9);
  border-radius: inherit;
  pointer-events: none;
}

.photo-stack-gallery__stack__card img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  user-select: none;
}

.photo-stack-gallery__stack__slot:nth-child(1) {
  z-index: 1;
}

.photo-stack-gallery__stack__slot:nth-child(1) .photo-stack-gallery__stack__card {
  transform: translate(-19px, 1px) rotate(-9deg);
}

.photo-stack-gallery__stack__slot:nth-child(2) {
  z-index: 3;
}

.photo-stack-gallery__stack__slot:nth-child(2) .photo-stack-gallery__stack__card {
  transform: translateY(8px) rotate(1deg);
}

.photo-stack-gallery__stack__slot:nth-child(3) {
  z-index: 2;
}

.photo-stack-gallery__stack__slot:nth-child(3) .photo-stack-gallery__stack__card {
  transform: translate(20px, 2px) rotate(9deg);
}

.photo-stack-gallery__stack[data-fanned="true"]
  .photo-stack-gallery__stack__slot:nth-child(1)
  .photo-stack-gallery__stack__card {
  transform: translate(-29px, -4px) rotate(-12deg);
}

.photo-stack-gallery__stack[data-fanned="true"]
  .photo-stack-gallery__stack__slot:nth-child(2)
  .photo-stack-gallery__stack__card {
  transform: translateY(2px) rotate(0deg);
}

.photo-stack-gallery__stack[data-fanned="true"]
  .photo-stack-gallery__stack__slot:nth-child(3)
  .photo-stack-gallery__stack__card {
  transform: translate(30px, -3px) rotate(12deg);
}

.photo-stack-gallery__gallery {
  position: fixed;
  z-index: 90;
  inset: 0;
  overflow: clip;
  background: transparent;
  color: var(--foreground, #111);
  isolation: isolate;
}

.photo-stack-gallery__dialog {
  position: fixed;
  inset: 0;
}

.photo-stack-gallery__gallery__surface {
  position: fixed;
  z-index: 89;
  inset: 0;
  background: var(--background, #fff);
}

.photo-stack-gallery__detail {
  position: fixed;
  z-index: 91;
  inset: 0;
  overflow: clip;
  color: var(--foreground);
  pointer-events: none;
}

.photo-stack-gallery__detail__media,
.photo-stack-gallery__detail__meta {
  pointer-events: auto;
}

.photo-stack-gallery__gallery__stage {
  --gallery-card-width: clamp(104px, 31vw, 154px);
  position: absolute;
  top: max(24px, env(safe-area-inset-top));
  right: 20px;
  bottom: calc(32px + var(--menu-bar-height, 64px) + 28px);
  left: 20px;
  max-width: 520px;
  margin-inline: auto;
}

.photo-stack-gallery__gallery__slot {
  appearance: none;
  position: absolute;
  display: block;
  width: var(--gallery-card-width);
  aspect-ratio: 3 / 4;
  border: 0;
  padding: 0;
  background: transparent;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

.photo-stack-gallery__gallery__card,
.photo-stack-gallery__detail__media {
  position: relative;
  display: block;
  width: 100%;
  height: 100%;
  overflow: clip;
  border-radius: var(--radius-4xl, 32px);
  background: var(--hover-bg, rgba(127, 127, 127, 0.12));
  box-shadow: var(--elevated-media-shadow, 0 8px 24px rgba(0, 0, 0, 0.16));
}

.photo-stack-gallery__gallery__card {
  transform: rotate(var(--card-rotation));
  transition: transform 280ms cubic-bezier(0.22, 1, 0.36, 1);
}

.photo-stack-gallery__gallery__slot[data-selected="true"] {
  z-index: 2;
}

.photo-stack-gallery__gallery__card::after,
.photo-stack-gallery__detail__media::after {
  content: "";
  position: absolute;
  z-index: 2;
  inset: 0;
  border: 2px solid rgba(255, 255, 255, 0.9);
  border-radius: inherit;
  pointer-events: none;
}

.photo-stack-gallery__gallery__card img,
.photo-stack-gallery__detail__media img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  user-select: none;
}

.photo-stack-gallery__detail__preview-image,
.photo-stack-gallery__detail__full-image {
  position: absolute;
  inset: 0;
}

.photo-stack-gallery__detail__full-image {
  z-index: 1;
}

.photo-stack-gallery__gallery__slot:nth-child(1) {
  top: 1%;
  left: 4%;
}

.photo-stack-gallery__gallery__slot:nth-child(2) {
  top: 6%;
  right: 3%;
}

.photo-stack-gallery__gallery__slot:nth-child(3) {
  top: 35%;
  left: 1%;
}

.photo-stack-gallery__gallery__slot:nth-child(4) {
  top: 40%;
  right: 4%;
}

.photo-stack-gallery__gallery__slot:nth-child(5) {
  bottom: 1%;
  left: 4%;
}

.photo-stack-gallery__gallery__slot:nth-child(6) {
  right: 3%;
  bottom: 0;
}

.photo-stack-gallery__gallery__slot:focus-visible {
  outline: 2px solid color-mix(in srgb, var(--foreground) 56%, transparent);
  outline-offset: 7px;
  border-radius: var(--radius-4xl, 32px);
}

.photo-stack-gallery__gallery__close {
  appearance: none;
  display: grid;
  width: 100%;
  height: 100%;
  padding: 0;
  place-items: center;
  border: 0;
  border-radius: var(--menu-shell-radius, 999px);
  background: transparent;
  color: inherit;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

/* The close control is portaled into a host-owned navigation surface. Marking
   that surface keeps the press response identical across site and Playground
   integrations without coupling this reusable component to either menu ID. */
[data-photo-gallery-navigation-host]:has(.photo-stack-gallery__gallery__close) {
  transform-origin: center;
  transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
}

[data-photo-gallery-navigation-host]:has(.photo-stack-gallery__gallery__close:active) {
  transform: scale(0.9);
  transition-duration: 100ms;
  transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}

@media (prefers-reduced-motion: reduce) {
  [data-photo-gallery-navigation-host]:has(.photo-stack-gallery__gallery__close) {
    transition: none;
    transform: none;
  }
}

.photo-stack-gallery__gallery__close:disabled {
  pointer-events: none;
}

.photo-stack-gallery__gallery__close svg {
  width: var(--menu-icon-size, 20px);
  height: var(--menu-icon-size, 20px);
}

.photo-stack-gallery__gallery__close path {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.75;
  stroke-linecap: round;
}

.photo-stack-gallery__gallery__close:focus-visible {
  outline: 2px solid color-mix(in srgb, var(--foreground) 42%, transparent);
  outline-offset: 4px;
}

.photo-stack-gallery__navigation-fallback {
  position: fixed;
  z-index: 100;
  right: 50%;
  bottom: max(24px, env(safe-area-inset-bottom));
  width: 56px;
  height: 56px;
  translate: 50% 0;
  border-radius: 999px;
  background: var(--menu-surface, #111);
  color: var(--menu-ink, #fff);
  box-shadow: var(--elevated-media-shadow, 0 8px 24px rgba(0, 0, 0, 0.16));
}

.photo-stack-gallery__detail__content {
  --photo-stack-gallery__detail-frame-width: min(84vw, calc(62dvh * 0.75), 500px);

  position: absolute;
  inset: max(20px, env(safe-area-inset-top)) 20px
    calc(32px + var(--menu-bar-height, 64px) + 24px);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 22px;
}

.photo-stack-gallery__detail__media {
  width: var(--photo-stack-gallery__detail-frame-width);
  height: auto;
  aspect-ratio: 3 / 4;
  border-radius: var(--radius-5xl, 48px);
}

.photo-stack-gallery__detail__meta {
  display: flex;
  box-sizing: border-box;
  width: var(--photo-stack-gallery__detail-frame-width);
  padding-inline: 40px;
  flex-direction: column;
  align-items: center;
}

.photo-stack-gallery__detail__copy {
  width: 100%;
  text-align: center;
}

.photo-stack-gallery__detail__copy p {
  margin: 0;
}

.photo-stack-gallery__detail__caption {
  margin: 0;
  color: color-mix(in srgb, var(--foreground) 62%, transparent);
  font-size: 0.875rem;
  font-weight: 400;
  line-height: 1.4;
}

.photo-stack-gallery__detail__link {
  color: inherit;
  text-decoration: underline;
  text-decoration-color: var(--subtle);
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
  transition: text-decoration-color 200ms ease;
}

.photo-stack-gallery__detail__link:hover {
  text-decoration-color: var(--muted);
}

@media (hover: hover) {
  .photo-stack-gallery__gallery__slot:hover .photo-stack-gallery__gallery__card {
    transform: translateY(-5px) rotate(var(--card-rotation)) scale(1.025);
  }

  .photo-stack-gallery__gallery__close:hover {
    background: color-mix(in srgb, var(--menu-surface) 94%, var(--menu-ink));
  }
}

@media (min-width: 700px) {
  .photo-stack-gallery__gallery__stage {
    --gallery-card-width: clamp(136px, 14vw, 208px);
    top: max(40px, env(safe-area-inset-top));
    right: 48px;
    bottom: calc(32px + var(--menu-bar-height) + 40px);
    left: 48px;
    max-width: 1120px;
  }

  .photo-stack-gallery__gallery__slot:nth-child(1) {
    top: 6%;
    left: 3%;
  }

  .photo-stack-gallery__gallery__slot:nth-child(2) {
    top: 1%;
    right: auto;
    left: calc(50% - var(--gallery-card-width) / 2);
  }

  .photo-stack-gallery__gallery__slot:nth-child(3) {
    top: 8%;
    right: 3%;
    left: auto;
  }

  .photo-stack-gallery__gallery__slot:nth-child(4) {
    top: 52%;
    right: auto;
    left: 17%;
  }

  .photo-stack-gallery__gallery__slot:nth-child(5) {
    top: 58%;
    right: auto;
    bottom: auto;
    left: calc(50% - var(--gallery-card-width) / 2);
  }

  .photo-stack-gallery__gallery__slot:nth-child(6) {
    top: 50%;
    right: 17%;
    bottom: auto;
  }

  .photo-stack-gallery__detail__content {
    --photo-stack-gallery__detail-frame-width: min(44vw, calc(64dvh * 0.75), 500px);

    inset-inline: 48px;
    gap: 24px;
  }
}

@media (max-height: 640px) {
  .photo-stack-gallery__detail__content {
    --photo-stack-gallery__detail-frame-width: min(
      84vw,
      calc((100dvh - 260px) * 0.75),
      500px
    );

    gap: 14px;
  }

  .photo-stack-gallery__detail__meta {
    width: min(100%, 360px);
    padding-inline: 24px;
  }
}

@media (max-height: 640px) and (orientation: landscape) {
  .photo-stack-gallery__detail__content {
    --photo-stack-gallery__detail-frame-width: min(
      44vw,
      calc((100dvh - 240px) * 0.75),
      500px
    );
  }
}

@media (prefers-reduced-motion: reduce) {
  .photo-stack-gallery__stack__card,
  .photo-stack-gallery__gallery__card {
    transition: none;
  }
}
function invariant(condition, message) {
  if (!condition) throw new Error(`PhotoGallery: ${message}`);
}

function isNonEmptyString(value) {
  return typeof value === "string" && value.trim().length > 0;
}

export function createPhotoGalleryModel(photos, previewIds) {
  invariant(Array.isArray(photos) && photos.length === 6, "provide exactly six photos.");
  invariant(
    Array.isArray(previewIds) &&
      previewIds.length === 3 &&
      new Set(previewIds).size === 3,
    "provide exactly three unique preview IDs."
  );

  const photoIds = photos.map(({ id }) => id);
  invariant(photoIds.every(isNonEmptyString), "every photo needs a non-empty ID.");
  invariant(new Set(photoIds).size === photos.length, "provide unique photo IDs.");

  for (const photo of photos) {
    invariant(isNonEmptyString(photo.previewSrc), `photo “${photo.id}” needs a preview source.`);
    invariant(isNonEmptyString(photo.src), `photo “${photo.id}” needs a full image source.`);
    invariant(isNonEmptyString(photo.alt), `photo “${photo.id}” needs non-empty alt text.`);
    invariant(photo.caption !== undefined && photo.caption !== null, `photo “${photo.id}” needs a caption.`);
  }

  const byId = new Map(photos.map((photo) => [photo.id, photo]));
  for (const previewId of previewIds) {
    invariant(byId.has(previewId), `missing preview photo “${previewId}”.`);
  }

  const previewPhotos = previewIds.map((id) => byId.get(id));
  const previewIdSet = new Set(previewIds);
  const galleryPhotos = [
    ...previewPhotos,
    ...photos.filter(({ id }) => !previewIdSet.has(id)),
  ];

  return {
    photos,
    previewPhotos,
    galleryPhotos,
    photoIds: new Set(photoIds),
  };
}

export function hasMeaningfulScroll(origin, current, threshold = 8) {
  return (
    Math.abs(current.x - origin.x) >= threshold ||
    Math.abs(current.y - origin.y) >= threshold
  );
}

export function reducePhotoGalleryState(state, event, validPhotoIds) {
  switch (event.type) {
    case "HOVER_START":
      return state.view === "rest" ? { view: "preview", pinned: false } : state;
    case "HOVER_END":
      return state.view === "preview" && !state.pinned ? { view: "rest" } : state;
    case "TRIGGER_ACTIVATE":
      if (state.view === "rest") return { view: "preview", pinned: true };
      if (state.view === "preview") {
        return state.pinned ? { view: "rest" } : { view: "preview", pinned: true };
      }
      return state;
    case "STACK_ACTIVATE":
      return state.view === "preview" ? { view: "gallery" } : state;
    case "SELECT_PHOTO":
      return (state.view === "gallery" || state.view === "gallery-return") &&
        validPhotoIds.has(event.photoId)
        ? { view: "detail", photoId: event.photoId }
        : state;
    case "BACK":
    case "CLOSE":
      if (state.view === "detail") {
        return { view: "gallery-return", photoId: state.photoId };
      }
      if (state.view === "gallery" || state.view === "gallery-return") {
        return { view: "stack-return" };
      }
      return state.view === "preview" ? { view: "rest" } : state;
    case "GALLERY_RETURN_COMPLETE":
      return state.view === "gallery-return" ? { view: "gallery" } : state;
    case "STACK_RETURN_COMPLETE":
      return state.view === "stack-return" ? { view: "rest" } : state;
    default:
      return state;
  }
}
export type PhotoGalleryState =
  | { view: "rest" }
  | { view: "preview"; pinned: boolean }
  | { view: "gallery" }
  | { view: "detail"; photoId: string }
  | { view: "gallery-return"; photoId: string }
  | { view: "stack-return" };

export type PhotoGalleryEvent =
  | { type: "HOVER_START" }
  | { type: "HOVER_END" }
  | { type: "TRIGGER_ACTIVATE" }
  | { type: "STACK_ACTIVATE" }
  | { type: "SELECT_PHOTO"; photoId: string }
  | { type: "BACK" }
  | { type: "CLOSE" }
  | { type: "GALLERY_RETURN_COMPLETE" }
  | { type: "STACK_RETURN_COMPLETE" };

export type PhotoGalleryModelPhoto = {
  id: string;
  previewSrc: string;
  src: string;
  alt: string;
  caption: unknown;
};

export type PhotoGalleryModel<Photo extends PhotoGalleryModelPhoto> = {
  photos: readonly Photo[];
  previewPhotos: readonly Photo[];
  galleryPhotos: readonly Photo[];
  photoIds: ReadonlySet<string>;
};

export function createPhotoGalleryModel<Photo extends PhotoGalleryModelPhoto>(
  photos: readonly Photo[],
  previewIds: readonly string[]
): PhotoGalleryModel<Photo>;

export function hasMeaningfulScroll(
  origin: { x: number; y: number },
  current: { x: number; y: number },
  threshold?: number
): boolean;

export function reducePhotoGalleryState(
  state: PhotoGalleryState,
  event: PhotoGalleryEvent,
  validPhotoIds: ReadonlySet<string>
): PhotoGalleryState;
export const PHOTO_GALLERY_LAYOUT_TRANSITION = Object.freeze({
  type: "spring",
  duration: 0.5,
  bounce: 0,
});

export const PHOTO_GALLERY_MENU_MORPH_TRANSITION = Object.freeze({
  ...PHOTO_GALLERY_LAYOUT_TRANSITION,
  duration: 0.42,
});

export const PHOTO_GALLERY_STACK_RETURN_LAYOUT_TRANSITION = Object.freeze({
  ...PHOTO_GALLERY_LAYOUT_TRANSITION,
  duration: 0.38,
  bounce: 0.08,
});

export const PHOTO_GALLERY_STACK_RETURN_DISSOLVE = Object.freeze({
  duration: 0.15,
  delay: PHOTO_GALLERY_STACK_RETURN_LAYOUT_TRANSITION.duration - 0.15,
  ease: "easeOut",
});
export { default as PhotoGallery } from "./PhotoGallery";
export type {
  PhotoGalleryNavigationUpdate,
  PhotoGalleryPhoto,
  PhotoGalleryProps,
} from "./PhotoGallery";
import { useCallback, useEffect, useState } from "react";

import {
  PhotoGallery,
  type PhotoGalleryNavigationUpdate,
  type PhotoGalleryPhoto,
} from "../components/photo-gallery";

const photos: readonly PhotoGalleryPhoto[] = [
  {
    id: "ocean",
    label: "Ocean",
    previewSrc: "/playground/photo-stack-gallery/preview/ocean.webp",
    src: "/playground/photo-stack-gallery/full/ocean.webp",
    alt: "Sunlight catching the surface of restless ocean water",
    caption: "Sunlight breaking across restless water.",
  },
  {
    id: "trees",
    label: "Trees",
    previewSrc: "/playground/photo-stack-gallery/preview/trees.webp",
    src: "/playground/photo-stack-gallery/full/trees.webp",
    alt: "Green tree canopy framing a clear blue summer sky",
    caption: "A clear summer sky, framed by the canopy.",
  },
  {
    id: "building",
    label: "Building",
    previewSrc: "/playground/photo-stack-gallery/preview/building.webp",
    src: "/playground/photo-stack-gallery/full/building.webp",
    alt: "Classical stone columns beneath a dramatic clouded sky",
    caption: "Old columns reaching into a stormy sky.",
    focalPoint: { x: 58, y: 50 },
  },
  {
    id: "beach",
    label: "Beach",
    previewSrc: "/playground/photo-stack-gallery/preview/beach.webp",
    src: "/playground/photo-stack-gallery/full/beach.webp",
    alt: "Muted evening light over a rocky ocean shoreline",
    caption: "Soft light settling over a rocky shoreline.",
  },
  {
    id: "vineyard",
    label: "Vineyard",
    previewSrc: "/playground/photo-stack-gallery/preview/vineyard.webp",
    src: "/playground/photo-stack-gallery/full/vineyard.webp",
    alt: "Rows of bare orchard trees disappearing into dense fog",
    caption: "Rows of bare trees fading into the morning fog.",
    focalPoint: { x: 54, y: 50 },
  },
  {
    id: "swamp",
    label: "Wetland",
    previewSrc: "/playground/photo-stack-gallery/preview/swamp.webp",
    src: "/playground/photo-stack-gallery/full/swamp.webp",
    alt: "Still water winding between bare trees at the edge of a wood",
    caption: "Still water winding through the edge of the woods.",
  },
];

const previewIds = ["trees", "building", "ocean"] as const;

function PhotoGalleryCatalogPreview({ active }: { active: boolean }) {
  const previewPhotos = previewIds.map((id) => {
    const photo = photos.find((candidate) => candidate.id === id);
    if (!photo) throw new Error(`Missing catalog preview photo “${id}”.`);
    return photo;
  });

  return (
    <div className="photo-stack-gallery__catalog" data-photo-stack-thumbnail>
      <span className="photo-stack-gallery__catalog__stack">
        <span
          className="photo-stack-gallery__stack"
          data-fanned={active}
          aria-hidden="true"
        >
          {previewPhotos.map((photo) => (
            <span className="photo-stack-gallery__stack__slot" key={photo.id}>
              <span
                className="photo-stack-gallery__stack__card"
                data-photo-stack-thumbnail-card
              >
                <img src={photo.previewSrc} alt="" draggable="false" />
              </span>
            </span>
          ))}
        </span>
      </span>
      <span
        className="inline-trigger photo-stack-gallery__catalog__label"
        data-decoration="wave"
      >
        Hover me
      </span>
    </div>
  );
}

export default function PhotoGalleryDemo({
  preview = false,
  previewActive = false,
}: {
  preview?: boolean;
  previewActive?: boolean;
}) {
  const [navigationTarget, setNavigationTarget] = useState<HTMLElement | null>(null);
  const [pageContentTarget, setPageContentTarget] = useState<HTMLElement | null>(null);

  useEffect(() => {
    setNavigationTarget(document.getElementById("playground-gallery-layer"));
    setPageContentTarget(document.getElementById("panel-preview"));
  }, []);

  const handleNavigationChange = useCallback(
    (detail: PhotoGalleryNavigationUpdate) => {
      document.dispatchEvent(
        new CustomEvent("playground-menu:set-mode", { detail })
      );
    },
    []
  );

  if (preview) {
    return <PhotoGalleryCatalogPreview active={previewActive} />;
  }

  return (
    <div className="photo-stack-gallery-demo">
      <PhotoGallery
        photos={photos}
        previewIds={previewIds}
        triggerLabel="Hover me"
        dialogLabel="Photo gallery"
        navigationTarget={navigationTarget}
        pageContentTarget={pageContentTarget}
        onNavigationChange={handleNavigationChange}
        labels={{
          open: "Open photo gallery",
          close: "Close photo gallery",
          back: "Back to photo gallery",
        }}
      />
    </div>
  );
}
# Photo gallery

A six-photo React gallery that starts as an inline trigger, reveals a three-card preview stack, and expands into a full-screen gallery with individual detail views.

## Install

```sh
npm install motion react react-dom
```

Copy this folder into your project and import the component from its barrel:

```tsx
import {
  PhotoGallery,
  type PhotoGalleryPhoto,
} from "./photo-gallery";
```

The component imports `PhotoGallery.css` itself. Place the component inside a React client boundary when using a framework that renders components on the server.

## Data contract

The gallery currently requires exactly six photos and exactly three preview IDs. Each preview ID must match one of the six photo IDs.

```tsx
const photos: readonly PhotoGalleryPhoto[] = [
  {
    id: "ocean",
    previewSrc: "/images/ocean-preview.webp",
    src: "/images/ocean.webp",
    alt: "Sunlight catching the ocean surface",
    caption: "Sunlight breaking across restless water.",
    focalPoint: { x: 50, y: 50 },
  },
  // Five more photos…
];

const previewIds = ["trees", "building", "ocean"] as const;
```

Use lightweight preview images for the stack and gallery, then higher-quality images for the detail view. The included demo generates previews at 480 × 640 and full images at 960 × 1280. Keep the same crop and aspect ratio across both versions so the shared-layout transition does not jump.

## Basic usage

```tsx
<PhotoGallery
  photos={photos}
  previewIds={previewIds}
  triggerLabel="Click me"
  dialogLabel="Photo gallery"
/>
```

`triggerLabel` is the visible control. `dialogLabel` names the full-screen dialog for assistive technology. Optional `labels` customize the open, close, and back accessible labels.

## Integrating host navigation

The component can coordinate with an existing page shell without owning it:

- `navigationTarget` is the host element that should remain interactive while the gallery is open, such as a menu layer that morphs into the close control.
- `pageContentTarget` is the page region made inert while the dialog is active.
- `onNavigationChange` reports `default` or `gallery` mode and the opening, closing, or settled phase so the host can animate its own navigation.

Both targets are optional. If supplied, pass stable DOM elements and keep them mounted for the duration of the transition.

## Behavior and accessibility

- Hover or keyboard focus reveals the compact stack temporarily.
- Activating the trigger pins the stack; activating the stack opens the gallery.
- Each gallery photo opens a detail view. The contextual control returns to the gallery, then closes it.
- Escape follows the same progressive path. Focus returns to the trigger after the gallery fully closes.
- The dialog locks page scrolling and makes the configured page content inert while it is active.
- Keyboard users can reach the trigger, stack, every gallery card, and the contextual control.
- Reduced motion is respected through `useReducedMotion`; spatial choreography is skipped when the user requests reduced motion.

## Styling

The CSS uses inherited font settings and the host's `--foreground`, `--background`, `--hover-bg`, `--elevated-media-shadow`, and menu geometry tokens, with fallbacks where appropriate. Adapt those tokens or the namespaced selectors in `PhotoGallery.css`; keep preview and detail geometry aligned if you change card sizes or radii because the layout animation interpolates between them.

The component is intentionally strict about photo count and preview count. If you need a different gallery size, update the model validation, gallery layout, rotations, and transition tests together rather than bypassing the constraints.
Photo gallery