diff --git a/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.spec.tsx b/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.spec.tsx
--- a/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.spec.tsx
+++ b/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.spec.tsx
@@ -1,12 +1,240 @@
-import { render } from '@testing-library/react';
+import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { useState, type ReactNode } from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import LightboxModal from "./lightbox-modal";
 
-import LightboxModal from './lightbox-modal';
+const mocks = vi.hoisted(() => {
+  function carousel(selector: string) {
+    const listeners = new Map<string, Set<() => void>>();
+    const count = () => document.querySelectorAll(selector).length;
+    const api = {
+      index: 0,
+      listeners,
+      selectedScrollSnap: () => api.index,
+      canScrollPrev: () => api.index > 0,
+      canScrollNext: () => api.index < count() - 1,
+      scrollPrev: () => api.scrollTo(api.index - 1),
+      scrollNext: () => api.scrollTo(api.index + 1),
+      scrollTo: (index: number) => {
+        const next = Math.max(0, Math.min(index, count() - 1));
+        if (next !== api.index) {
+          api.index = next;
+          api.emit("select");
+        }
+      },
+      reInit: () => {
+        api.scrollTo(api.index);
+        api.emit("reInit");
+      },
+      on: (event: string, callback: () => void) => {
+        if (!listeners.has(event)) listeners.set(event, new Set());
+        listeners.get(event)!.add(callback);
+        return api;
+      },
+      off: (event: string, callback: () => void) => {
+        listeners.get(event)?.delete(callback);
+        return api;
+      },
+      emit: (event: string) => {
+        listeners.get(event)?.forEach((callback) => callback());
+      },
+    };
+    return api;
+  }
 
-describe('LightboxModal', () => {
-  
-  it('should render successfully', () => {
-    const { baseElement } = render(<LightboxModal />);
-    expect(baseElement).toBeTruthy();
+  return {
+    main: carousel(".embla__slide"),
+    thumbs: carousel(".embla-thumbs__slide"),
+    getBatch: vi.fn(),
+    getMedia: vi.fn().mockResolvedValue({ success: false }),
+  };
+});
+
+vi.mock("embla-carousel-react", () => ({
+  default: (options: { containScroll?: string }) => {
+    const [node, setNode] = useState<HTMLElement | null>(null);
+    return [setNode, node ? (options.containScroll ? mocks.thumbs : mocks.main) : undefined];
+  },
+}));
+vi.mock("@storyteller/ui-modal", () => ({
+  Modal: Object.assign(
+    ({ isOpen, children }: { isOpen: boolean; children: ReactNode }) =>
+      isOpen ? <div>{children}</div> : null,
+    { DragHandle: ({ children }: { children: ReactNode }) => <>{children}</> },
+  ),
+}));
+vi.mock("@storyteller/api", () => ({
+  MediaFilesApi: class {
+    GetMediaFilesByBatchToken = mocks.getBatch;
+    GetMediaFileByToken = mocks.getMedia;
+  },
+  PromptsApi: class {},
+}));
+vi.mock("./tags/TagsSection", () => ({ TagsSection: () => null }));
+vi.mock("@storyteller/ui-audio-player", () => ({ WaveformAudioPlayer: () => null }));
+vi.mock("@storyteller/google-analytics", () => ({ gtagEvent: () => {} }));
+
+const BUNDLES = [
+  { imageUrl: "a0.png", imageUrls: ["a0.png", "a1.png", "a2.png"] },
+  { imageUrl: "b0.png", imageUrls: ["b0.png", "b1.png", "b2.png"] },
+];
+const BASE_PROPS = {
+  isOpen: true,
+  onClose: () => {},
+  onCloseGallery: () => {},
+  mediaClass: "image",
+};
+
+beforeEach(() => {
+  vi.useFakeTimers();
+  mocks.main.index = 0;
+  mocks.thumbs.index = 0;
+  mocks.main.listeners.clear();
+  mocks.thumbs.listeners.clear();
+  mocks.getBatch.mockReset();
+  mocks.getBatch.mockImplementation(({ batchToken }: { batchToken: string }) =>
+    Promise.resolve({
+      success: true,
+      data: [0, 1, 2].map((index) => ({
+        token: `${batchToken}${index}`,
+        media_links: { cdn_url: `${batchToken}${index}.png` },
+      })),
+    }),
+  );
+});
+
+afterEach(() => {
+  cleanup();
+  vi.useRealTimers();
+});
+
+describe("LightboxModal carousel navigation", () => {
+  it("remembers each bundle's last slide when navigating down and back up", () => {
+    renderNavigator(BUNDLES);
+    selectThumbnail(3);
+    fireEvent.keyDown(window, { key: "ArrowDown" });
+    expectSelection(0);
+    selectThumbnail(2);
+
+    fireEvent.keyDown(window, { key: "ArrowUp" });
+    expectSelection(2);
+    fireEvent.keyDown(window, { key: "ArrowDown" });
+    expectSelection(1);
+    expect(mocks.main.listeners.get("select")?.size).toBe(1);
+    expect(mocks.main.listeners.get("reInit")?.size).toBe(1);
+  });
+
+  it("keeps cross-bundle navigation at the left/right boundaries", () => {
+    renderNavigator(BUNDLES);
+    fireEvent.keyDown(window, { key: "ArrowRight" });
+    expectSelection(1);
+    fireEvent.keyDown(window, { key: "ArrowRight" });
+    expectSelection(2);
+    fireEvent.keyDown(window, { key: "ArrowRight" });
+    expectSelection(0);
+    expect(screen.getByAltText("Generated image 1").getAttribute("src")).toContain("b0.png");
+
+    fireEvent.keyDown(window, { key: "ArrowLeft" });
+    expectSelection(2);
+    expect(screen.getByAltText("Generated image 1").getAttribute("src")).toContain("a0.png");
+  });
+
+  it.each(["video", "audio"])("restores an image selection after visiting %s media", (mediaClass) => {
+    renderNavigator([BUNDLES[0], { imageUrl: "other-media", mediaClass }]);
+    selectThumbnail(3);
+    fireEvent.keyDown(window, { key: "ArrowDown" });
+    expect(screen.queryByAltText("Thumbnail 3")).toBeNull();
+    expect(mocks.main.listeners.get("select")?.size).toBe(0);
+
+    fireEvent.keyDown(window, { key: "ArrowUp" });
+    expectSelection(2);
+  });
+
+  it("restores the saved slide after a fetched batch replaces its one-image placeholder", async () => {
+    renderNavigator([
+      { imageUrl: "a0.png", mediaId: "a0", batchImageToken: "a" },
+      { imageUrl: "b0.png", mediaId: "b0", batchImageToken: "b" },
+    ]);
+    await loadBatch();
+    selectThumbnail(3);
+    fireEvent.keyDown(window, { key: "ArrowDown" });
+    expect(screen.queryByAltText("Thumbnail 3")).toBeNull();
+    expect(screen.getByAltText("Generated image 1").getAttribute("src")).toContain("b0.png");
+
+    fireEvent.keyDown(window, { key: "ArrowUp" });
+    await loadBatch();
+    expectSelection(2);
+    act(() => mocks.main.reInit());
+    expectSelection(2);
+  });
+
+  it("honors a different opening image when a fetched batch is reordered", async () => {
+    const onDownloadClicked = vi.fn();
+    const props = { ...BASE_PROPS, batchImageToken: "a", onDownloadClicked };
+    const view = render(<LightboxModal {...props} mediaId="a0" imageUrl="a0.png" />);
+    await loadBatch();
+    selectThumbnail(3);
+
+    view.rerender(<LightboxModal {...props} mediaId="a1" imageUrl="a1.png" />);
+    await loadBatch();
+    expectSelection(0);
+    fireEvent.click(screen.getByRole("button", { name: "Download" }));
+    expect(onDownloadClicked).toHaveBeenCalledWith("a1.png", "image", undefined);
+
+    view.rerender(<LightboxModal {...props} mediaId="a0" imageUrl="a0.png" />);
+    await loadBatch();
+    expectSelection(2);
+  });
+
+  it("uses the requested initial slide for a new entry and after reopening", () => {
+    const props = { ...BASE_PROPS, ...BUNDLES[0], initialIndex: 1 };
+    const view = render(<LightboxModal {...props} />);
+    expectSelection(1);
+    selectThumbnail(3);
+
+    view.rerender(<LightboxModal {...props} isOpen={false} />);
+    view.rerender(<LightboxModal {...props} />);
+    expectSelection(1);
+    view.rerender(<LightboxModal {...props} initialIndex={0} />);
+    expectSelection(0);
+
+    view.unmount();
+    expect(mocks.main.listeners.get("select")?.size).toBe(0);
+    expect(mocks.main.listeners.get("reInit")?.size).toBe(0);
   });
-  
 });
+
+function renderNavigator(bundles: Array<{
+  imageUrl: string;
+  imageUrls?: string[];
+  mediaId?: string;
+  batchImageToken?: string;
+  mediaClass?: string;
+}>) {
+  function Navigator() {
+    const [index, setIndex] = useState(0);
+    return (
+      <LightboxModal
+        {...BASE_PROPS}
+        {...bundles[index]}
+        onNavigatePrev={() => setIndex((current) => Math.max(0, current - 1))}
+        onNavigateNext={() => setIndex((current) => Math.min(bundles.length - 1, current + 1))}
+      />
+    );
+  }
+  return render(<Navigator />);
+}
+
+function selectThumbnail(number: number) {
+  fireEvent.click(screen.getByAltText(`Thumbnail ${number}`).closest("button")!);
+}
+
+function expectSelection(index: number) {
+  const thumbnail = screen.getByAltText(`Thumbnail ${index + 1}`).closest("button")!;
+  expect(thumbnail.classList.contains("border-white")).toBe(true);
+  expect(mocks.main.selectedScrollSnap()).toBe(index);
+}
+
+async function loadBatch() {
+  await act(async () => vi.advanceTimersByTimeAsync(200));
+}
diff --git a/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx b/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx
--- a/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx
+++ b/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx
@@ -150,8 +150,17 @@ export function LightboxModal({
   const [isPromptClamped, setIsPromptClamped] = useState<boolean>(false);
   const [promptCopied, setPromptCopied] = useState<boolean>(false);
   const promptCopiedTimeoutRef = useRef<number | null>(null);
-  const [batchImages, setBatchImages] = useState<string[] | null>(null);
-  const [batchTokens, setBatchTokens] = useState<string[] | null>(null);
+  const [batch, setBatch] = useState<{
+    token: string;
+    images: string[];
+    tokens: string[];
+    loading: boolean;
+  } | null>(null);
+  const batchImages = batch && batch.token === batchImageToken ? batch.images : null;
+  const batchTokens = batch && batch.token === batchImageToken ? batch.tokens : null;
+  const isBatchLoading = Boolean(
+    batchImageToken && (!batch || batch.token !== batchImageToken || batch.loading),
+  );
   const [creator, setCreator] = useState<UserInfo | null>(null);
   const [shareCopied, setShareCopied] = useState<boolean>(false);
   const shareCopiedTimeoutRef = useRef<number | null>(null);
@@ -193,7 +202,6 @@ export function LightboxModal({
   useEffect(() => {
     if (isOpen) {
       setRefPreviewUrl(null);
-      setSelectedIndex(0);
       setMediaLoaded(false);
       setMediaWidth(null);
       setMediaHeight(null);
@@ -297,14 +305,12 @@ export function LightboxModal({
   // Fetch batch images — debounced + cancellable like fetchPrompt above.
   useEffect(() => {
     if (!batchImageToken) {
-      setBatchImages(null);
-      setBatchTokens(null);
+      setBatch(null);
       return;
     }
 
     // Clear stale batch data immediately
-    setBatchImages(null);
-    setBatchTokens(null);
+    setBatch({ token: batchImageToken, images: [], tokens: [], loading: true });
 
     let cancelled = false;
 
@@ -338,20 +344,21 @@ export function LightboxModal({
               return 0;
             });
 
-            setBatchImages(sortedItems.map((item) => item.url));
-            setBatchTokens(sortedItems.map((item) => item.token));
+            setBatch({
+              token: batchImageToken,
+              images: sortedItems.map((item) => item.url),
+              tokens: sortedItems.map((item) => item.token),
+              loading: false,
+            });
           } else {
-            setBatchImages(null);
-            setBatchTokens(null);
+            setBatch({ token: batchImageToken, images: [], tokens: [], loading: false });
           }
         } else {
-          setBatchImages(null);
-          setBatchTokens(null);
+          setBatch({ token: batchImageToken, images: [], tokens: [], loading: false });
         }
       } catch (error: unknown) {
         if (cancelled) return;
-        setBatchImages(null);
-        setBatchTokens(null);
+        setBatch({ token: batchImageToken, images: [], tokens: [], loading: false });
       }
     }, 200);
 
@@ -372,6 +379,14 @@ export function LightboxModal({
   }, [batchImages, imageUrls, imageUrl]);
 
   const [selectedIndex, setSelectedIndex] = useState(0);
+  // A different opening image can reorder a fetched batch; treat it as a new entry point.
+  const carouselKey = JSON.stringify([
+    batchImageToken,
+    mediaId ?? imageUrl ?? imageUrls?.[0],
+    initialIndex,
+  ]);
+  const rememberedIndices = useRef(new Map<string, number>());
+  const restoringSelection = useRef(false);
   const carouselOptions: EmblaOptionsType = useMemo(
     () => ({ loop: false }),
     [],
@@ -418,30 +433,62 @@ export function LightboxModal({
   );
 
   const onSelect = useCallback(() => {
-    if (!emblaMainApi || !emblaThumbsApi) return;
+    if (!emblaMainApi || !isOpen || restoringSelection.current) return;
     const index = emblaMainApi.selectedScrollSnap();
     setSelectedIndex(index);
-    emblaThumbsApi.scrollTo(index);
-  }, [emblaMainApi, emblaThumbsApi]);
+    emblaThumbsApi?.scrollTo(index);
+    if (!isBatchLoading) rememberedIndices.current.set(carouselKey, index);
+  }, [emblaMainApi, emblaThumbsApi, isOpen, isBatchLoading, carouselKey]);
 
-  useEffect(() => {
-    if (!emblaMainApi) return;
-    onSelect();
-    emblaMainApi.on("select", onSelect).on("reInit", onSelect);
-  }, [emblaMainApi, onSelect]);
+  const restoreSelection = useCallback(() => {
+    if (!isOpen) return;
+    const rememberedIndex = rememberedIndices.current.get(carouselKey);
+    const index = Math.max(
+      0,
+      Math.min(rememberedIndex ?? initialIndex ?? 0, effectiveImageUrls.length - 1),
+    );
+    const wasRestoring = restoringSelection.current;
+    restoringSelection.current = true;
+    try {
+      setSelectedIndex(index);
+      emblaMainApi?.scrollTo(index, true);
+      emblaThumbsApi?.scrollTo(index, true);
+    } finally {
+      restoringSelection.current = wasRestoring;
+    }
+  }, [
+    isOpen,
+    carouselKey,
+    initialIndex,
+    effectiveImageUrls.length,
+    emblaMainApi,
+    emblaThumbsApi,
+  ]);
 
-  // Re-initialize Embla when slides change so it picks up the new DOM children
   useEffect(() => {
-    emblaMainApi?.reInit();
-    emblaThumbsApi?.reInit();
-  }, [effectiveImageUrls, emblaMainApi, emblaThumbsApi]);
+    if (!emblaMainApi) return;
+    emblaMainApi.on("select", onSelect).on("reInit", restoreSelection);
+    return () => {
+      emblaMainApi.off("select", onSelect).off("reInit", restoreSelection);
+    };
+  }, [emblaMainApi, onSelect, restoreSelection]);
 
+  // Restore each bundle's last selection after navigation or delayed batch loading.
   useEffect(() => {
-    const idx = initialIndex ?? 0;
-    setSelectedIndex(idx);
-    emblaMainApi?.scrollTo(idx, true);
-    emblaThumbsApi?.scrollTo(idx, true);
-  }, [batchImageToken, imageUrl, emblaMainApi, emblaThumbsApi]);
+    if (!isOpen) {
+      rememberedIndices.current.clear();
+      setSelectedIndex(0);
+      return;
+    }
+    restoringSelection.current = true;
+    try {
+      emblaMainApi?.reInit();
+      emblaThumbsApi?.reInit();
+      restoreSelection();
+    } finally {
+      restoringSelection.current = false;
+    }
+  }, [isOpen, effectiveImageUrls, emblaMainApi, emblaThumbsApi, restoreSelection]);
 
   const selectedImageUrl = effectiveImageUrls[selectedIndex] ?? null;
   const actionUrl =
diff --git a/netlify.toml b/netlify.toml
new file mode 100644
--- /dev/null
+++ b/netlify.toml
@@ -0,0 +1,7 @@
+# The `storyteller-docs` Netlify site still builds this repository, but its
+# build command (./script/netlify_build_docs.sh) and the docs themselves now
+# live in storytold/artcraft-services, so every deploy preview here fails.
+# Skip those builds until the Netlify site is repointed or disconnected, then
+# delete this file.
+[build]
+  ignore = "exit 0"
