Kaynağa Gözat

File Manager previews: fullscreen and zoom, image previews, double-click to open (#2990)

Thomansky 3 gün önce
ebeveyn
işleme
226826f3bd
40 değiştirilmiş dosya ile 3498 ekleme ve 657 silme
  1. 57 23
      backend/app/api/routes/library.py
  2. 31 9
      backend/app/main.py
  3. 25 0
      backend/tests/integration/test_library_api.py
  4. 154 0
      backend/tests/integration/test_library_preview_thumbnail_api.py
  5. 52 29
      backend/tests/integration/test_security_headers.py
  6. 31 6
      frontend/scripts/check-browser-baseline.mjs
  7. 235 0
      frontend/src/__tests__/components/ImagePreviewModal.test.tsx
  8. 210 0
      frontend/src/__tests__/components/ModelViewerControls.test.tsx
  9. 45 1
      frontend/src/__tests__/components/ModelViewerModal.test.tsx
  10. 347 7
      frontend/src/__tests__/components/PdfPreviewModal.test.tsx
  11. 140 0
      frontend/src/__tests__/components/PreviewModalShell.test.tsx
  12. 125 1
      frontend/src/__tests__/components/SpreadsheetPreviewModal.test.tsx
  13. 127 0
      frontend/src/__tests__/hooks/useElementFullscreen.test.tsx
  14. 80 0
      frontend/src/__tests__/pages/FileManagerPage.test.tsx
  15. 335 0
      frontend/src/__tests__/pages/FileManagerPreviewOpening.test.tsx
  16. 306 0
      frontend/src/components/ImagePreviewModal.tsx
  17. 49 11
      frontend/src/components/ModelViewer.tsx
  18. 299 318
      frontend/src/components/ModelViewerModal.tsx
  19. 303 86
      frontend/src/components/PdfPreviewModal.tsx
  20. 101 0
      frontend/src/components/PreviewModalShell.tsx
  21. 118 109
      frontend/src/components/SpreadsheetPreviewModal.tsx
  22. 72 0
      frontend/src/hooks/useElementFullscreen.ts
  23. 22 0
      frontend/src/hooks/usePreviewFullscreen.ts
  24. 3 0
      frontend/src/i18n/locales/de.ts
  25. 3 0
      frontend/src/i18n/locales/en.ts
  26. 3 0
      frontend/src/i18n/locales/es.ts
  27. 3 0
      frontend/src/i18n/locales/fr.ts
  28. 3 0
      frontend/src/i18n/locales/it.ts
  29. 3 0
      frontend/src/i18n/locales/ja.ts
  30. 3 0
      frontend/src/i18n/locales/ko.ts
  31. 3 0
      frontend/src/i18n/locales/nl.ts
  32. 3 0
      frontend/src/i18n/locales/pt-BR.ts
  33. 3 0
      frontend/src/i18n/locales/ru.ts
  34. 3 0
      frontend/src/i18n/locales/sv.ts
  35. 3 0
      frontend/src/i18n/locales/tr.ts
  36. 3 0
      frontend/src/i18n/locales/uk.ts
  37. 3 0
      frontend/src/i18n/locales/zh-CN.ts
  38. 3 0
      frontend/src/i18n/locales/zh-TW.ts
  39. 146 56
      frontend/src/pages/FileManagerPage.tsx
  40. 43 1
      frontend/vite.config.ts

+ 57 - 23
backend/app/api/routes/library.py

@@ -17,7 +17,7 @@ from pathlib import Path
 
 from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
 from fastapi.responses import FileResponse as FastAPIFileResponse
-from sqlalchemy import distinct, func, select
+from sqlalchemy import distinct, func, select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
@@ -813,11 +813,12 @@ def create_image_thumbnail(file_path: Path, thumbnails_dir: Path, max_size: int
 IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif"}
 
 # File types whose thumbnails are rendered client-side and uploaded back
-# (#2976). The server has no renderer for these formats — STEP would need
-# OpenCascade, PDF a rasteriser — so the browser posts its first preview
-# render to POST /files/{id}/preview-thumbnail instead. Kept to exactly
-# these types so the endpoint can never overwrite a server-generated
-# STL/3MF/G-code/image thumbnail.
+# (#2976). The server has no renderer for STEP (that would need OpenCascade)
+# or the spreadsheet types, so the browser posts its first preview render to
+# POST /files/{id}/preview-thumbnail instead. PDF is rendered server-side with
+# PDFium when it lands and stays here for a PDF that renderer cannot read.
+# Kept to exactly these types so the endpoint can never overwrite a
+# server-generated STL/3MF/G-code/image thumbnail.
 CLIENT_THUMBNAIL_TYPES = {"step", "stp", "pdf", "csv", "xlsx", "ods"}
 
 # Photos of the printed result (#3077): same allowlist and naming as the
@@ -835,6 +836,15 @@ MAX_PHOTO_BYTES = 10 * 1024 * 1024
 # 256px PNG (a few tens of KB); anything near this limit is not a thumbnail.
 MAX_CLIENT_THUMBNAIL_BYTES = 2 * 1024 * 1024
 
+# Upper bound on the *decoded* size, checked against the header before any
+# pixels are allocated: a few-KB PNG can declare 12000x7000 and still be under
+# PIL's own decompression-bomb limit, which would be ~340 MB of RGBA.
+MAX_CLIENT_THUMBNAIL_EDGE = 2048
+
+# What the endpoint stores. The grid renders at ~256px, so anything larger is
+# downscaled rather than kept.
+STORED_CLIENT_THUMBNAIL_EDGE = 512
+
 
 async def _backfill_external_stl_thumbnails(folder_ids: list[int]) -> None:
     """Generate STL thumbnails for an external folder tree in the background.
@@ -5421,9 +5431,11 @@ async def upload_preview_thumbnail(
 
     STEP, PDF and spreadsheet previews are rendered in the browser; the FE
     posts its first render here so the grid gets a thumbnail without the
-    server needing OpenCascade or a PDF rasteriser. Only file types in
-    ``CLIENT_THUMBNAIL_TYPES`` are accepted, and only while the file has no
-    thumbnail yet — a stored thumbnail is never replaced by this route.
+    server needing OpenCascade. A PDF normally has its PDFium thumbnail from
+    upload already, so for PDFs this only fills the gap for a file PDFium
+    could not read. Only file types in ``CLIENT_THUMBNAIL_TYPES`` are
+    accepted, and only while the file has no thumbnail yet — a stored
+    thumbnail is never replaced by this route.
     """
     user, can_modify_all = auth_result
 
@@ -5455,26 +5467,48 @@ async def upload_preview_thumbnail(
     from PIL import Image, UnidentifiedImageError
 
     try:
-        with Image.open(io.BytesIO(content)) as img:
-            img.load()
-            if img.format != "PNG":
+        # Image.open() reads the header only. Both checks below happen before
+        # load(), so a declared-but-never-delivered canvas is refused rather
+        # than allocated. DecompressionBombError derives straight from
+        # Exception, so it has to be named explicitly — open() itself raises
+        # it once the declared size passes PIL's own limit.
+        with Image.open(io.BytesIO(content)) as source:
+            if source.format != "PNG":
                 raise HTTPException(status_code=400, detail="Thumbnail must be a PNG image")
-            if img.mode not in ("RGB", "RGBA"):
-                img = img.convert("RGBA")
-            # The grid renders at ~256px; cap outliers instead of storing them.
-            if img.width > 512 or img.height > 512:
-                img.thumbnail((512, 512), Image.Resampling.LANCZOS)
-            thumbnails_dir = get_library_thumbnails_dir()
-            thumb_filename = f"{uuid.uuid4().hex}.png"
-            thumb_path = thumbnails_dir / thumb_filename  # SEC-PATH-OK: thumb_filename = uuid.uuid4().hex + ".png"
-            img.save(thumb_path, "PNG", optimize=True)
+            if max(source.size) > MAX_CLIENT_THUMBNAIL_EDGE:
+                raise HTTPException(status_code=400, detail="Thumbnail image dimensions too large")
+            source.load()
+            img = source.convert("RGBA") if source.mode not in ("RGB", "RGBA") else source.copy()
     except HTTPException:
         raise
-    except (UnidentifiedImageError, OSError, ValueError) as e:
+    except (UnidentifiedImageError, OSError, ValueError, Image.DecompressionBombError) as e:
         raise HTTPException(status_code=400, detail="Invalid thumbnail image") from e
 
-    file.thumbnail_path = to_relative_path(thumb_path)
+    if max(img.size) > STORED_CLIENT_THUMBNAIL_EDGE:
+        img.thumbnail((STORED_CLIENT_THUMBNAIL_EDGE, STORED_CLIENT_THUMBNAIL_EDGE), Image.Resampling.LANCZOS)
+
+    thumbnails_dir = get_library_thumbnails_dir()
+    thumb_filename = f"{uuid.uuid4().hex}.png"
+    thumb_path = thumbnails_dir / thumb_filename  # SEC-PATH-OK: thumb_filename = uuid.uuid4().hex + ".png"
+    # Outside the decode guard on purpose: a full disk or an unwritable
+    # thumbnail directory is ours, not "Invalid thumbnail image".
+    try:
+        img.save(thumb_path, "PNG", optimize=True)
+    except OSError as e:
+        logger.error("Failed to store preview thumbnail for file %s: %s", file_id, e)
+        raise HTTPException(status_code=500, detail="Failed to store thumbnail") from e
+
+    # Two previews of the same file can reach this point together; the loser
+    # of the UPDATE takes its PNG back off disk instead of orphaning it.
+    result = await db.execute(
+        update(LibraryFile)
+        .where(LibraryFile.id == file_id, LibraryFile.thumbnail_path.is_(None))
+        .values(thumbnail_path=to_relative_path(thumb_path))
+    )
     await db.commit()
+    if result.rowcount == 0:
+        thumb_path.unlink(missing_ok=True)
+        return ClientThumbnailResponse(updated=False)
 
     return ClientThumbnailResponse(updated=True)
 

+ 31 - 9
backend/app/main.py

@@ -10163,10 +10163,18 @@ def _frame_ancestors(default_value: str) -> str:
     return f"frame-ancestors {default_value};"
 
 
-# The Vite-emitted STEP preview worker chunk (#2976): src/workers/
-# stepPreview.worker.ts becomes /assets/stepPreview.worker-<hash>.js. Matched
-# exactly so the eval-relaxed CSP below can never apply to any other asset.
+# The two Vite-emitted worker assets that compile WebAssembly (#2976). Both
+# patterns are anchored on the exact emitted name so the relaxed policies
+# below can never apply to any other asset.
+#   src/workers/stepPreview.worker.ts -> /assets/stepPreview.worker-<hash>.js
+#   pdfjs-dist/legacy/build/pdf.worker.min.mjs?worker&url
+#                                     -> /assets/pdf.worker.min-<hash>.js
+# Vite also emits a one-line chunk under the second name that only exports the
+# worker's URL. The page imports it as a module, and a module script is run
+# under the importing document's policy, never its own response's, so it
+# matching as well changes nothing.
 _STEP_WORKER_ASSET_RE = re.compile(r"^/assets/stepPreview\.worker-[\w-]+\.js$")
+_PDF_WORKER_ASSET_RE = re.compile(r"^/assets/pdf\.worker\.min-[\w-]+\.js$")
 
 
 @app.middleware("http")
@@ -10236,6 +10244,20 @@ async def security_headers_middleware(request, call_next):
             "object-src 'none'; "
             "base-uri 'self'; " + _frame_ancestors("'none'")
         )
+    elif _PDF_WORKER_ASSET_RE.match(request.url.path):
+        # pdf.js decodes JPEG2000/JBIG2 images and ICC colour with WebAssembly
+        # and fetches those modules from /assets/pdfjs/wasm/ (#2976). Same CSP3
+        # rule as the STEP worker above: the policy that governs a dedicated
+        # worker is the one delivered with its own script, so the wasm compile
+        # has to be permitted here rather than on the document. Unlike the STEP
+        # worker this one needs no JS eval, so it gets 'wasm-unsafe-eval' only.
+        response.headers["Content-Security-Policy"] = (
+            "default-src 'self'; "
+            "script-src 'self' 'wasm-unsafe-eval'; "
+            "connect-src 'self'; "
+            "object-src 'none'; "
+            "base-uri 'self'; " + _frame_ancestors("'none'")
+        )
     else:
         # The streaming overlay is embedded same-origin by the URL builder's
         # preview in Settings (#1422), so this branch allows 'self'.
@@ -10257,14 +10279,14 @@ async def security_headers_middleware(request, call_next):
         # sidebar link's site included -- still cannot frame the consent
         # screen to bait a click.
         embeddable_same_origin = request.url.path.startswith("/overlay/") or request.url.path == "/connect/authorize"
-        # 'wasm-unsafe-eval' permits WebAssembly compilation ONLY — it does
-        # not allow eval()/Function() for JS, unlike 'unsafe-eval'. Needed by
-        # the STEP preview, which triangulates in the browser via OpenCascade
-        # compiled to WASM (#2976). Browsers that predate the keyword ignore
-        # it and simply keep blocking wasm, so this never widens JS execution.
+        # No 'wasm-unsafe-eval' here: nothing compiles WebAssembly on the main
+        # thread. Both wasm consumers — the STEP preview and pdf.js's image
+        # decoders (#2976) — run in dedicated workers, which CSP3 governs by
+        # the policy served with their own script, so each gets it in its own
+        # branch above and the document policy stays as strict as it was.
         response.headers["Content-Security-Policy"] = (
             "default-src 'self'; "
-            f"script-src 'self' 'wasm-unsafe-eval' 'nonce-{csp_nonce}'; "
+            f"script-src 'self' 'nonce-{csp_nonce}'; "
             "style-src 'self' 'unsafe-inline'; "
             "img-src 'self' data: blob:; "
             "media-src 'self' blob:; "

+ 25 - 0
backend/tests/integration/test_library_api.py

@@ -1190,6 +1190,31 @@ endsolid cube"""
         # No thumbnail should be generated when disabled
         assert result["thumbnail_path"] is None
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_image_stores_a_thumbnail(self, async_client: AsyncClient, db_session):
+        """An uploaded image gets its own grid thumbnail, so PNGs are not blank cards (#2976)."""
+        from PIL import Image
+
+        buffer = io.BytesIO()
+        Image.new("RGB", (640, 480), (0, 174, 66)).save(buffer, format="PNG")
+
+        files = {"file": ("photo.png", buffer.getvalue(), "image/png")}
+        response = await async_client.post("/api/v1/library/files", files=files)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["file_type"] == "png"
+        assert result["thumbnail_path"]
+
+        from backend.app.api.routes.library import to_absolute_path
+
+        thumb = to_absolute_path(result["thumbnail_path"])
+        assert thumb is not None and thumb.exists()
+        with Image.open(thumb) as thumbnail:
+            assert thumbnail.format == "PNG"
+            assert max(thumbnail.size) <= 256
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_extract_zip_with_stl_thumbnail_param(self, async_client: AsyncClient, db_session):

+ 154 - 0
backend/tests/integration/test_library_preview_thumbnail_api.py

@@ -7,6 +7,7 @@ types, and never replacing an existing thumbnail.
 """
 
 import io
+import zlib
 
 import pytest
 from httpx import AsyncClient
@@ -14,6 +15,7 @@ from PIL import Image
 
 from backend.app.core.config import settings as app_settings
 from backend.app.models.library import LibraryFile
+from backend.tests.integration.test_ownership_permissions import TestOwnershipPermissionsSetup
 
 
 def _png_bytes(size: tuple[int, int] = (300, 300), color: str = "red") -> bytes:
@@ -22,6 +24,22 @@ def _png_bytes(size: tuple[int, int] = (300, 300), color: str = "red") -> bytes:
     return buf.getvalue()
 
 
+def _png_claiming(width: int, height: int) -> bytes:
+    """A ~70-byte PNG whose IHDR declares a canvas it never delivers.
+
+    This is the shape that costs memory: the header is what a decoder sizes
+    its buffer from, and the payload stays small enough to pass any upload cap.
+    """
+    raw = bytearray(_png_bytes(size=(1, 1)))
+    # 8-byte signature, then IHDR: length(4) type(4) data(13) crc(4).
+    ihdr = raw[8:33]
+    ihdr[8:12] = width.to_bytes(4, "big")
+    ihdr[12:16] = height.to_bytes(4, "big")
+    ihdr[21:25] = zlib.crc32(bytes(ihdr[4:21])).to_bytes(4, "big")
+    raw[8:33] = ihdr
+    return bytes(raw)
+
+
 @pytest.fixture
 def isolated_storage(monkeypatch, tmp_path):
     """Point thumbnail storage at a throwaway directory."""
@@ -169,3 +187,139 @@ class TestPreviewThumbnailUpload:
             files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
         )
         assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_rejects_a_canvas_it_would_have_to_allocate(
+        self, async_client: AsyncClient, db_session, file_factory, isolated_storage
+    ):
+        """12000x7000 is under PIL's bomb limit and would decode for real.
+
+        A few KB of upload turns into ~340 MB of pixels, so the declared size
+        has to be refused from the header, before load() is ever reached.
+        """
+        library_file = await file_factory(file_type="step")
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
+            files={"thumbnail": ("preview.png", _png_claiming(12000, 7000), "image/png")},
+        )
+
+        assert response.status_code == 400
+        await db_session.refresh(library_file)
+        assert library_file.thumbnail_path is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_rejects_a_decompression_bomb_header(
+        self, async_client: AsyncClient, file_factory, isolated_storage
+    ):
+        """PIL raises DecompressionBombError straight off Exception.
+
+        It is neither an OSError nor a ValueError, so it escaped the decode
+        guard and surfaced as a 500 — it is a bad request like any other.
+        """
+        library_file = await file_factory(file_type="pdf", filename="doc.pdf", file_path="library/files/doc.pdf")
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
+            files={"thumbnail": ("preview.png", _png_claiming(20000, 20000), "image/png")},
+        )
+
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_storage_failure_is_not_reported_as_a_bad_image(
+        self, async_client: AsyncClient, monkeypatch, file_factory, isolated_storage
+    ):
+        """A full disk is ours to own, not "Invalid thumbnail image"."""
+
+        payload = _png_bytes()
+        library_file = await file_factory(file_type="xlsx")
+
+        def _no_space(*args, **kwargs):
+            raise OSError(28, "No space left on device")
+
+        monkeypatch.setattr(Image.Image, "save", _no_space)
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
+            files={"thumbnail": ("preview.png", payload, "image/png")},
+        )
+
+        assert response.status_code == 500
+        assert response.json()["detail"] != "Invalid thumbnail image"
+
+
+class TestPreviewThumbnailOwnership(TestOwnershipPermissionsSetup):
+    """The ownership branch of the upload route (#2976).
+
+    Reuses the shared auth setup: Operators hold library:update_own only, so
+    they are the group that can tell the two branches apart.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_can_upload_for_their_own_file(
+        self, async_client: AsyncClient, db_session, auth_setup, file_factory, isolated_storage
+    ):
+        library_file = await file_factory(file_type="step", created_by_id=auth_setup["operator_user"]["id"])
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
+        )
+
+        assert response.status_code == 200
+        await db_session.refresh(library_file)
+        assert library_file.thumbnail_path
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_upload_for_someone_elses_file(
+        self, async_client: AsyncClient, db_session, auth_setup, file_factory, isolated_storage
+    ):
+        library_file = await file_factory(file_type="step", created_by_id=auth_setup["operator2_user"]["id"])
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
+        )
+
+        assert response.status_code == 403
+        await db_session.refresh(library_file)
+        assert library_file.thumbnail_path is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_upload_for_an_ownerless_file(
+        self, async_client: AsyncClient, auth_setup, file_factory, isolated_storage
+    ):
+        """created_by_id is NULL — an *_own permission owns nothing here."""
+        library_file = await file_factory(file_type="step", created_by_id=None)
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
+        )
+
+        assert response.status_code == 403
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_admin_can_upload_for_an_ownerless_file(
+        self, async_client: AsyncClient, auth_setup, file_factory, isolated_storage
+    ):
+        library_file = await file_factory(file_type="step", created_by_id=None)
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
+            headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
+            files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
+        )
+
+        assert response.status_code == 200

+ 52 - 29
backend/tests/integration/test_security_headers.py

@@ -316,31 +316,39 @@ async def test_spa_csp_nonce_changes_per_request(async_client: AsyncClient):
     assert len(nonces) == 5, f"nonces should be per-request, got {nonces!r}"
 
 
-# ─── #2976: STEP preview needs WebAssembly, and only WebAssembly ─────────
+# ─── #2976: WebAssembly is confined to the two preview workers ───────────
 
 
-@pytest.mark.asyncio
-@pytest.mark.integration
-async def test_spa_csp_allows_wasm_but_not_eval(async_client: AsyncClient):
-    """script-src must carry 'wasm-unsafe-eval' but never 'unsafe-eval' (#2976).
-
-    The STEP preview triangulates in the browser via OpenCascade compiled to
-    WASM; without 'wasm-unsafe-eval' the nonce-based CSP blocks
-    WebAssembly.instantiate() and the preview dies with a CompileError.
-    'wasm-unsafe-eval' permits wasm compilation only — JS eval()/Function()
-    stay blocked, which is what the second assertion pins.
+def _script_src_tokens(resp) -> list[str]:
+    """The script-src directive of a response's CSP, split into whole tokens.
+
+    Whole tokens, because 'wasm-unsafe-eval' contains the text "unsafe-eval"
+    and a substring check would read as a pass either way.
     """
-    resp = await async_client.get("/api/v1/auth/status")
     csp = resp.headers.get("Content-Security-Policy", "")
-    script_src = next(
+    directive = next(
         (d.strip() for d in csp.split(";") if d.strip().startswith("script-src")),
         "",
     )
-    assert "'wasm-unsafe-eval'" in script_src, f"script-src must allow wasm compilation: {script_src!r}"
-    # Substring check must not be fooled by 'wasm-unsafe-eval' containing
-    # "unsafe-eval" — compare whole tokens.
-    tokens = script_src.split()
-    assert "'unsafe-eval'" not in tokens, f"script-src must not allow JS eval: {script_src!r}"
+    return directive.split()
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_spa_csp_permits_no_kind_of_eval(async_client: AsyncClient):
+    """The document policy allows neither wasm compilation nor JS eval (#2976).
+
+    Nothing on the main thread compiles WebAssembly: the STEP preview and
+    pdf.js's image decoders both run in dedicated workers, which get their own
+    policies below. So the SPA document stays exactly as strict as it was
+    before the previews landed.
+    """
+    resp = await async_client.get("/api/v1/auth/status")
+    tokens = _script_src_tokens(resp)
+
+    assert tokens, "the SPA response must carry a script-src directive"
+    assert "'wasm-unsafe-eval'" not in tokens, f"document must not compile wasm: {tokens!r}"
+    assert "'unsafe-eval'" not in tokens, f"document must not allow JS eval: {tokens!r}"
 
 
 @pytest.mark.asyncio
@@ -355,20 +363,35 @@ async def test_step_worker_asset_csp_relaxes_eval_only_for_that_file(async_clien
     SPA document itself — must stay nonce-strict. Both requests 404 in the
     test checkout; the security middleware stamps headers regardless.
     """
-
-    def script_src_tokens(resp) -> list[str]:
-        csp = resp.headers.get("Content-Security-Policy", "")
-        directive = next(
-            (d.strip() for d in csp.split(";") if d.strip().startswith("script-src")),
-            "",
-        )
-        return directive.split()
-
     worker = await async_client.get("/assets/stepPreview.worker-Ck9aB12c.js")
-    assert "'unsafe-eval'" in script_src_tokens(worker), "step worker script must be allowed to eval"
+    assert "'unsafe-eval'" in _script_src_tokens(worker), "step worker script must be allowed to eval"
 
     other = await async_client.get("/assets/index-Ck9aB12c.js")
-    assert "'unsafe-eval'" not in script_src_tokens(other), "ordinary assets must stay eval-free"
+    assert "'unsafe-eval'" not in _script_src_tokens(other), "ordinary assets must stay eval-free"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_pdf_worker_asset_csp_allows_wasm_without_eval(async_client: AsyncClient):
+    """pdf.js's worker may compile wasm, and nothing more (#2976).
+
+    Its JPEG2000/JBIG2/ICC decoders are WebAssembly fetched from
+    /assets/pdfjs/wasm/; without 'wasm-unsafe-eval' on the worker script's own
+    response they fail to compile and those images and colour spaces silently
+    drop out. JS eval stays blocked — unlike the STEP worker, pdf.js needs
+    none.
+    """
+    worker = await async_client.get("/assets/pdf.worker.min-Ck9aB12c.js")
+    tokens = _script_src_tokens(worker)
+
+    assert "'wasm-unsafe-eval'" in tokens, f"pdf worker must be allowed to compile wasm: {tokens!r}"
+    assert "'unsafe-eval'" not in tokens, f"pdf worker must not be allowed to eval JS: {tokens!r}"
+
+    # Only the worker: pdf.js's own chunk and the modal that loads it run on
+    # the page, whose policy stays wasm-free.
+    for path in ("/assets/pdf-Ck9aB12c.js", "/assets/PdfPreviewModal-Ck9aB12c.js"):
+        other = await async_client.get(path)
+        assert "'wasm-unsafe-eval'" not in _script_src_tokens(other), f"{path} must not get the worker's policy"
 
 
 # ─── #1460: HEAD on PWA bootstrap routes (manifest / sw / sw-register) ───

+ 31 - 6
frontend/scripts/check-browser-baseline.mjs

@@ -28,11 +28,37 @@
  */
 
 import { readdirSync, readFileSync } from 'node:fs';
-import { join, dirname, resolve } from 'node:path';
+import { join, dirname, relative, resolve } from 'node:path';
 import { fileURLToPath } from 'node:url';
 
 const ASSETS = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'static', 'assets');
 
+/**
+ * Every extension the build can emit executable JavaScript under. `.mjs`
+ * because a dependency's file imported with `?url` is copied verbatim under
+ * its own extension and bypasses `build.target` - the pdf.js worker shipped a
+ * class static block that way (#2976).
+ */
+const SCRIPT_EXTENSIONS = ['.js', '.mjs', '.cjs'];
+
+/**
+ * Every script under `dir`, recursively, as paths relative to ASSETS. The
+ * subdirectories matter: pdf.js's decoder fallbacks are published verbatim
+ * under assets/pdfjs/ (vite.config.ts) and run in its worker (#2976).
+ */
+function collectScripts(dir) {
+  const found = [];
+  for (const entry of readdirSync(dir, { withFileTypes: true })) {
+    const full = join(dir, entry.name);
+    if (entry.isDirectory()) {
+      found.push(...collectScripts(full));
+    } else if (SCRIPT_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {
+      found.push(relative(ASSETS, full));
+    }
+  }
+  return found;
+}
+
 /**
  * Each pattern must match only real occurrences of the feature. Anything that
  * needs context to tell a false positive from a real hit (regex flags, for
@@ -62,17 +88,16 @@ const FORBIDDEN = [
 
 let bundles;
 try {
-  // .mjs too: a dependency's worker imported with `?url` is copied verbatim
-  // under its own extension, bypasses `build.target`, and was invisible here -
-  // the pdf.js worker shipped a class static block that way (#2976).
-  bundles = readdirSync(ASSETS).filter((f) => f.endsWith('.js') || f.endsWith('.mjs'));
+  bundles = collectScripts(ASSETS);
 } catch {
   console.error(`check-browser-baseline: no build output at ${ASSETS} - run \`vite build\` first.`);
   process.exit(1);
 }
 
 if (bundles.length === 0) {
-  console.error(`check-browser-baseline: no .js or .mjs files in ${ASSETS} - did the build succeed?`);
+  console.error(
+    `check-browser-baseline: no ${SCRIPT_EXTENSIONS.join('/')} files in ${ASSETS} - did the build succeed?`,
+  );
   process.exit(1);
 }
 

+ 235 - 0
frontend/src/__tests__/components/ImagePreviewModal.test.tsx

@@ -0,0 +1,235 @@
+/**
+ * Tests for ImagePreviewModal (#2976).
+ *
+ * jsdom decodes nothing and lays nothing out, so the tests cover the modal's
+ * own logic — the authenticated fetch, the size guard, zoom input and the
+ * fullscreen wiring — rather than what the image looks like.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { ImagePreviewModal, IMAGE_PREVIEW_MAX_BYTES } from '../../components/ImagePreviewModal';
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getLibraryFileDownloadUrl: vi.fn((id: number) => `http://test/library/files/${id}/download`),
+  },
+  getAuthToken: () => 'token-123',
+}));
+
+const mockOnClose = vi.fn();
+
+function renderModal(props: Partial<Parameters<typeof ImagePreviewModal>[0]> = {}) {
+  return render(
+    <ImagePreviewModal
+      libraryFileId={11}
+      filename="plate.png"
+      fileSize={2048}
+      onClose={mockOnClose}
+      {...props}
+    />,
+  );
+}
+
+async function renderLoadedModal(props: Partial<Parameters<typeof ImagePreviewModal>[0]> = {}) {
+  const utils = renderModal(props);
+  const image = (await screen.findByAltText('plate.png')) as HTMLImageElement;
+  fireEvent.load(image);
+  return { ...utils, image, content: screen.getByTestId('image-preview-content') };
+}
+
+function scaleOf(image: HTMLImageElement): number {
+  const match = /scale\(([\d.]+)\)/.exec(image.style.transform);
+  return match ? Number(match[1]) : NaN;
+}
+
+describe('ImagePreviewModal', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.stubGlobal(
+      'fetch',
+      vi.fn(async () => new Response(new Uint8Array([137, 80, 78, 71]), { status: 200 })),
+    );
+    vi.stubGlobal('URL', {
+      ...URL,
+      createObjectURL: vi.fn(() => 'blob:image-preview'),
+      revokeObjectURL: vi.fn(),
+    });
+  });
+
+  afterEach(() => {
+    vi.unstubAllGlobals();
+  });
+
+  it('loads the file through the authenticated download URL', async () => {
+    await renderLoadedModal();
+
+    expect(fetch).toHaveBeenCalledWith('http://test/library/files/11/download', {
+      headers: { Authorization: 'Bearer token-123' },
+    });
+    expect(screen.getByAltText('plate.png')).toHaveAttribute('src', 'blob:image-preview');
+    expect(screen.getByText('plate.png')).toBeInTheDocument();
+  });
+
+  it('refuses a file too large to hold in memory', async () => {
+    renderModal({ fileSize: IMAGE_PREVIEW_MAX_BYTES + 1 });
+
+    expect(await screen.findByText(/too large to preview/i)).toBeInTheDocument();
+    expect(fetch).not.toHaveBeenCalled();
+  });
+
+  it('shows a message when the download fails', async () => {
+    vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 404 })));
+    vi.spyOn(console, 'error').mockImplementation(() => {});
+
+    renderModal();
+
+    expect(await screen.findByText('This file cannot be previewed.')).toBeInTheDocument();
+  });
+
+  it('starts fitted to the window and zooms from the buttons', async () => {
+    const user = userEvent.setup();
+    const { image } = await renderLoadedModal();
+
+    expect(scaleOf(image)).toBe(1);
+
+    await user.click(screen.getByRole('button', { name: 'Zoom in' }));
+    expect(scaleOf(image)).toBeCloseTo(1.25, 5);
+
+    await user.click(screen.getByRole('button', { name: 'Zoom out' }));
+    expect(scaleOf(image)).toBeCloseTo(1, 5);
+  });
+
+  it('zooms with the wheel around the pointer', async () => {
+    const { image, content } = await renderLoadedModal();
+
+    fireEvent.wheel(content, { deltaY: -100, clientX: 40, clientY: 30 });
+    await waitFor(() => expect(scaleOf(image)).toBeCloseTo(1.25, 5));
+
+    fireEvent.wheel(content, { deltaY: 100, clientX: 40, clientY: 30 });
+    await waitFor(() => expect(scaleOf(image)).toBeCloseTo(1, 5));
+  });
+
+  it('zooms from the keyboard and resets with 0', async () => {
+    const { image } = await renderLoadedModal();
+
+    fireEvent.keyDown(window, { key: '+' });
+    fireEvent.keyDown(window, { key: '+' });
+    await waitFor(() => expect(scaleOf(image)).toBeCloseTo(1.5625, 4));
+
+    fireEvent.keyDown(window, { key: '0' });
+    await waitFor(() => expect(scaleOf(image)).toBe(1));
+  });
+
+  it('stops at the zoom limits', async () => {
+    const { image } = await renderLoadedModal();
+
+    for (let i = 0; i < 20; i++) fireEvent.keyDown(window, { key: '+' });
+    await waitFor(() => expect(scaleOf(image)).toBe(8));
+
+    for (let i = 0; i < 30; i++) fireEvent.keyDown(window, { key: '-' });
+    await waitFor(() => expect(scaleOf(image)).toBe(0.4));
+  });
+
+  it('offers grab-to-pan only once the image is bigger than the window', async () => {
+    const { content } = await renderLoadedModal();
+
+    expect(content.className).not.toContain('cursor-grab');
+
+    fireEvent.keyDown(window, { key: '+' });
+    await waitFor(() => expect(content.className).toContain('cursor-grab'));
+  });
+
+  describe('a pointer that never reports its release', () => {
+    // jsdom has no PointerEvent, and testing-library then falls back to a plain
+    // Event without pointerId / pointerType / buttons.
+    class FakePointerEvent extends MouseEvent {
+      pointerId: number;
+      pointerType: string;
+      constructor(type: string, init: PointerEventInit = {}) {
+        super(type, init);
+        this.pointerId = init.pointerId ?? 0;
+        this.pointerType = init.pointerType ?? '';
+      }
+    }
+
+    /** jsdom lays nothing out, so the pan clamp needs real box sizes. */
+    function giveBoxes(content: HTMLElement, image: HTMLImageElement) {
+      Object.defineProperty(content, 'clientWidth', { configurable: true, value: 200 });
+      Object.defineProperty(content, 'clientHeight', { configurable: true, value: 200 });
+      Object.defineProperty(image, 'offsetWidth', { configurable: true, value: 400 });
+      Object.defineProperty(image, 'offsetHeight', { configurable: true, value: 400 });
+      content.setPointerCapture = vi.fn();
+    }
+
+    function translateOf(image: HTMLImageElement): string {
+      return /translate\(([^)]*)\)/.exec(image.style.transform)?.[1] ?? '';
+    }
+
+    beforeEach(() => {
+      Object.defineProperty(window, 'PointerEvent', { configurable: true, value: FakePointerEvent });
+    });
+
+    afterEach(() => {
+      delete (window as { PointerEvent?: unknown }).PointerEvent;
+    });
+
+    it('drops it when it leaves the image area, instead of panning on hover', async () => {
+      const { image, content } = await renderLoadedModal();
+      giveBoxes(content, image);
+
+      // Press at zoom 1 — no pointer capture is taken — then release somewhere
+      // else, so only pointerleave arrives.
+      fireEvent.pointerDown(content, { pointerId: 1, pointerType: 'mouse', buttons: 1, clientX: 100, clientY: 100 });
+      fireEvent.pointerLeave(content, { pointerId: 1, pointerType: 'mouse' });
+
+      fireEvent.keyDown(window, { key: '+' });
+      await waitFor(() => expect(scaleOf(image)).toBeCloseTo(1.25, 5));
+
+      fireEvent.pointerMove(content, { pointerId: 1, pointerType: 'mouse', buttons: 0, clientX: 160, clientY: 140 });
+
+      expect(translateOf(image)).toBe('0px, 0px');
+    });
+
+    it('drops it on the first buttonless move, so a later touch is not read as a pinch', async () => {
+      const { image, content } = await renderLoadedModal();
+      giveBoxes(content, image);
+
+      fireEvent.pointerDown(content, { pointerId: 1, pointerType: 'mouse', buttons: 1, clientX: 100, clientY: 100 });
+      fireEvent.keyDown(window, { key: '+' });
+      await waitFor(() => expect(scaleOf(image)).toBeCloseTo(1.25, 5));
+
+      // The mouse moves back over the image with nothing held down.
+      fireEvent.pointerMove(content, { pointerId: 1, pointerType: 'mouse', buttons: 0, clientX: 160, clientY: 140 });
+      expect(translateOf(image)).toBe('0px, 0px');
+
+      // One finger now drags alone: it pans, it does not pinch-zoom.
+      fireEvent.pointerDown(content, { pointerId: 2, pointerType: 'touch', clientX: 100, clientY: 100 });
+      fireEvent.pointerMove(content, { pointerId: 2, pointerType: 'touch', clientX: 130, clientY: 100 });
+
+      expect(scaleOf(image)).toBeCloseTo(1.25, 5);
+      expect(translateOf(image)).toBe('30px, 0px');
+    });
+
+    it('still pans while the button is held', async () => {
+      const { image, content } = await renderLoadedModal();
+      giveBoxes(content, image);
+
+      fireEvent.keyDown(window, { key: '+' });
+      await waitFor(() => expect(scaleOf(image)).toBeCloseTo(1.25, 5));
+
+      fireEvent.pointerDown(content, { pointerId: 1, pointerType: 'mouse', buttons: 1, clientX: 100, clientY: 100 });
+      fireEvent.pointerMove(content, { pointerId: 1, pointerType: 'mouse', buttons: 1, clientX: 120, clientY: 110 });
+
+      expect(translateOf(image)).toBe('20px, 10px');
+    });
+  });
+
+  it('closes on Escape', async () => {
+    await renderLoadedModal();
+
+    fireEvent.keyDown(window, { key: 'Escape' });
+    expect(mockOnClose).toHaveBeenCalledTimes(1);
+  });
+});

+ 210 - 0
frontend/src/__tests__/components/ModelViewerControls.test.tsx

@@ -0,0 +1,210 @@
+/**
+ * Zoom and resize behaviour of the 3D model viewer (#2976).
+ *
+ * Only what jsdom cannot provide is replaced: the WebGL renderer, the PMREM
+ * environment it would bake, and OrbitControls. Scene, camera and the STL
+ * loader are real, so the tests exercise the component's own framing maths.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { act, fireEvent, render, waitFor } from '@testing-library/react';
+import * as THREE from 'three';
+
+const mocks = vi.hoisted(() => ({
+  setSize: vi.fn(),
+  wheelSeen: vi.fn(),
+  controlsInstances: [] as Array<{ object: THREE.Camera; domElement: HTMLElement; target: THREE.Vector3; minDistance: number; maxDistance: number }>,
+}));
+
+vi.mock('three', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('three')>();
+  class FakeWebGLRenderer {
+    domElement = document.createElement('canvas');
+    shadowMap = { enabled: false, type: 0 };
+    toneMapping = 0;
+    toneMappingExposure = 1;
+    setSize(width: number, height: number) {
+      mocks.setSize(width, height);
+      this.domElement.style.width = `${width}px`;
+      this.domElement.style.height = `${height}px`;
+    }
+    setPixelRatio() {}
+    render() {}
+    dispose() {}
+  }
+  class FakePMREMGenerator {
+    fromScene() {
+      return { texture: { dispose() {} } };
+    }
+    dispose() {}
+  }
+  return { ...actual, WebGLRenderer: FakeWebGLRenderer, PMREMGenerator: FakePMREMGenerator };
+});
+
+vi.mock('three/examples/jsm/controls/OrbitControls.js', () => ({
+  OrbitControls: class {
+    object: THREE.Camera;
+    domElement: HTMLElement;
+    target = new THREE.Vector3();
+    enableDamping = false;
+    dampingFactor = 0;
+    minDistance = 0;
+    maxDistance = Infinity;
+    constructor(object: THREE.Camera, domElement: HTMLElement) {
+      this.object = object;
+      this.domElement = domElement;
+      // The real controls listen for wheel on the element they are handed;
+      // the fake does the same so the test can tell where events end up.
+      domElement.addEventListener('wheel', mocks.wheelSeen);
+      mocks.controlsInstances.push(this);
+    }
+    update() {}
+    dispose() {
+      this.domElement.removeEventListener('wheel', mocks.wheelSeen);
+    }
+  },
+}));
+
+vi.mock('../../api/client', () => ({
+  getAuthToken: () => null,
+}));
+
+import { ModelViewer } from '../../components/ModelViewer';
+
+// Binary STL of one triangle, enough for a bounding box.
+function tinyStl(): ArrayBuffer {
+  const buffer = new ArrayBuffer(84 + 50);
+  const view = new DataView(buffer);
+  view.setUint32(80, 1, true);
+  const floats = [0, 0, 1, 0, 0, 0, 20, 0, 0, 0, 20, 10];
+  floats.forEach((value, i) => view.setFloat32(84 + i * 4, value, true));
+  return buffer;
+}
+
+function setClientSize(element: HTMLElement, width: number, height: number) {
+  Object.defineProperty(element, 'clientWidth', { configurable: true, value: width });
+  Object.defineProperty(element, 'clientHeight', { configurable: true, value: height });
+}
+
+describe('ModelViewer controls', () => {
+  beforeEach(() => {
+    mocks.setSize.mockClear();
+    mocks.wheelSeen.mockClear();
+    mocks.controlsInstances.length = 0;
+    // jsdom lays nothing out; give every element a panel-sized box so the
+    // camera gets a real aspect ratio to frame against.
+    Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => 800 });
+    Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, get: () => 600 });
+    vi.stubGlobal(
+      'fetch',
+      vi.fn(async () => new Response(tinyStl(), { status: 200 })),
+    );
+    vi.stubGlobal(
+      'ResizeObserver',
+      class {
+        observe() {}
+        unobserve() {}
+        disconnect() {}
+      },
+    );
+  });
+
+  afterEach(() => {
+    vi.unstubAllGlobals();
+    delete (HTMLElement.prototype as { clientWidth?: number }).clientWidth;
+    delete (HTMLElement.prototype as { clientHeight?: number }).clientHeight;
+  });
+
+  async function renderLoaded() {
+    const utils = render(<ModelViewer url="/api/v1/library/files/1/download" fileType="stl" />);
+    const canvas = document.querySelector('canvas') as HTMLCanvasElement;
+    // Loaded once the zoom / reset buttons replace the spinner.
+    await waitFor(() => expect(document.querySelector('.lucide-zoom-in')).not.toBeNull());
+    return { ...utils, canvas, controls: mocks.controlsInstances[0] };
+  }
+
+  it('hands the canvas that is in the DOM to OrbitControls, so wheel events reach it', async () => {
+    const { canvas, controls } = await renderLoaded();
+
+    expect(controls.domElement).toBe(canvas);
+    expect(document.body.contains(canvas)).toBe(true);
+
+    fireEvent.wheel(canvas, { deltaY: -100 });
+    expect(mocks.wheelSeen).toHaveBeenCalledTimes(1);
+  });
+
+  it('bounds the dolly range and keeps the far plane beyond it', async () => {
+    const { controls } = await renderLoaded();
+    const camera = controls.object as THREE.PerspectiveCamera;
+
+    const framed = camera.position.distanceTo(controls.target);
+    expect(controls.maxDistance).toBeGreaterThan(framed);
+    expect(Number.isFinite(controls.maxDistance)).toBe(true);
+    expect(controls.minDistance).toBeGreaterThan(0);
+    // Zooming all the way out must never push the model past the far plane.
+    expect(camera.far).toBeGreaterThan(controls.maxDistance);
+  });
+
+  it('zoom buttons dolly along the view axis toward the orbit target', async () => {
+    const { controls } = await renderLoaded();
+    const camera = controls.object as THREE.PerspectiveCamera;
+    const before = camera.position.clone();
+    const distanceBefore = before.distanceTo(controls.target);
+    const directionBefore = before.clone().sub(controls.target).normalize();
+
+    fireEvent.click(document.querySelector('.lucide-zoom-in')!.closest('button')!);
+
+    const distanceAfter = camera.position.distanceTo(controls.target);
+    expect(distanceAfter).toBeCloseTo(distanceBefore * 0.8, 5);
+    const directionAfter = camera.position.clone().sub(controls.target).normalize();
+    expect(directionAfter.distanceTo(directionBefore)).toBeLessThan(1e-6);
+
+    fireEvent.click(document.querySelector('.lucide-zoom-out')!.closest('button')!);
+    expect(camera.position.distanceTo(controls.target)).toBeCloseTo(distanceBefore, 5);
+  });
+
+  it('resets to the framed view, not a fixed pose', async () => {
+    const { controls } = await renderLoaded();
+    const camera = controls.object as THREE.PerspectiveCamera;
+    const framedPosition = camera.position.clone();
+    const framedTarget = controls.target.clone();
+
+    fireEvent.click(document.querySelector('.lucide-zoom-in')!.closest('button')!);
+    fireEvent.click(document.querySelector('.lucide-rotate-ccw')!.closest('button')!);
+
+    expect(camera.position.distanceTo(framedPosition)).toBeLessThan(1e-6);
+    expect(controls.target.distanceTo(framedTarget)).toBeLessThan(1e-6);
+  });
+
+  it('resizes the canvas and camera when fullscreen changes', async () => {
+    const { canvas, controls } = await renderLoaded();
+    const camera = controls.object as THREE.PerspectiveCamera;
+    const container = canvas.parentElement as HTMLElement;
+    mocks.setSize.mockClear();
+
+    setClientSize(container, 1920, 1080);
+    act(() => {
+      document.dispatchEvent(new Event('fullscreenchange'));
+    });
+
+    expect(mocks.setSize).toHaveBeenCalledWith(1920, 1080);
+    expect(camera.aspect).toBeCloseTo(1920 / 1080, 5);
+    expect(canvas.style.width).toBe('1920px');
+  });
+
+  it('ignores a resize to zero (hidden container) instead of collapsing the camera', async () => {
+    const { canvas, controls } = await renderLoaded();
+    const camera = controls.object as THREE.PerspectiveCamera;
+    const container = canvas.parentElement as HTMLElement;
+    const aspect = camera.aspect;
+    mocks.setSize.mockClear();
+
+    setClientSize(container, 0, 0);
+    act(() => {
+      document.dispatchEvent(new Event('fullscreenchange'));
+    });
+
+    expect(mocks.setSize).not.toHaveBeenCalled();
+    expect(camera.aspect).toBe(aspect);
+  });
+});

+ 45 - 1
frontend/src/__tests__/components/ModelViewerModal.test.tsx

@@ -202,11 +202,55 @@ describe('ModelViewerModal', () => {
         // Look for the maximize icon button
         const buttons = screen.getAllByRole('button');
         const fullscreenButton = buttons.find(
-          (btn) => btn.querySelector('.lucide-maximize-2') || btn.title === 'Enter fullscreen'
+          (btn) => btn.querySelector('.lucide-maximize-2') || btn.title === 'Fullscreen'
         );
         expect(fullscreenButton).toBeInTheDocument();
       });
     });
+
+    it('double-click on the viewer requests fullscreen for the panel (#2976)', async () => {
+      const requestFullscreen = vi.fn().mockResolvedValue(undefined);
+      Object.defineProperty(document, 'fullscreenEnabled', { configurable: true, value: true });
+      Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', { configurable: true, value: requestFullscreen });
+      try {
+        render(
+          <ModelViewerModal
+            archiveId={1}
+            title="Test Model"
+            onClose={mockOnClose}
+          />
+        );
+        const viewer = await screen.findByTestId('model-viewer-area');
+
+        fireEvent.doubleClick(viewer);
+
+        expect(requestFullscreen).toHaveBeenCalledTimes(1);
+        const panel = screen.getByText('Test Model').closest('.flex-col');
+        expect(requestFullscreen.mock.instances[0]).toBe(panel);
+      } finally {
+        delete (document as { fullscreenEnabled?: boolean }).fullscreenEnabled;
+        delete (HTMLElement.prototype as { requestFullscreen?: () => Promise<void> }).requestFullscreen;
+      }
+    });
+
+    it('fills the viewport as a fallback where the Fullscreen API is missing', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+      const viewer = await screen.findByTestId('model-viewer-area');
+      const panel = screen.getByText('Test Model').closest('.flex-col') as HTMLElement;
+      // Windowed: the size every preview shares (#2976), not a per-modal max-w.
+      expect(panel.className).toContain('w-[min(1800px,96vw)]');
+
+      fireEvent.doubleClick(viewer);
+
+      expect(panel.className).toContain('max-w-none');
+      expect(screen.getByTitle('Exit fullscreen')).toBeInTheDocument();
+    });
   });
 
   describe('object count', () => {

+ 347 - 7
frontend/src/__tests__/components/PdfPreviewModal.test.tsx

@@ -3,25 +3,24 @@
  *
  * pdf.js cannot rasterise inside jsdom (no real canvas), so the library is
  * mocked at the module boundary; the tests cover the modal's own logic —
- * loading, page navigation, and error/size fallbacks.
+ * loading, page navigation, error/size fallbacks, zoom input and fullscreen.
  */
 
 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { render, screen, waitFor } from '@testing-library/react';
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { PdfPreviewModal } from '../../components/PdfPreviewModal';
 
 const pdfjsMocks = vi.hoisted(() => {
   const render = vi.fn(() => ({ promise: Promise.resolve(), cancel: vi.fn() }));
-  const getPage = vi.fn(async () => ({
-    getViewport: ({ scale }: { scale: number }) => ({ width: 600 * scale, height: 800 * scale }),
-    render,
-  }));
+  const defaultViewport = ({ scale }: { scale: number }) => ({ width: 600 * scale, height: 800 * scale });
+  const getViewport = vi.fn(defaultViewport);
+  const getPage = vi.fn(async () => ({ getViewport, render }));
   const getDocument = vi.fn(() => ({
     promise: Promise.resolve({ numPages: 3, getPage }),
     destroy: vi.fn(),
   }));
-  return { render, getPage, getDocument };
+  return { render, defaultViewport, getViewport, getPage, getDocument };
 });
 
 vi.mock('pdfjs-dist/legacy/build/pdf.mjs', () => ({
@@ -52,13 +51,49 @@ function renderModal(props: Partial<Parameters<typeof PdfPreviewModal>[0]> = {})
   );
 }
 
+// jsdom lays nothing out, so the fit scale bottoms out at the 0.1 floor and
+// the 600pt page is shown 60px wide at zoom 1.
+const CSS_WIDTH_AT_ZOOM_1 = 60;
+
+async function renderLoadedModal(props: Partial<Parameters<typeof PdfPreviewModal>[0]> = {}) {
+  const utils = renderModal(props);
+  await screen.findByText('Page 1 of 3');
+  await waitFor(() => expect(pdfjsMocks.render).toHaveBeenCalled());
+  const page = screen.getByTestId('pdf-preview-page');
+  const canvas = page.querySelector('canvas') as HTMLCanvasElement;
+  await waitFor(() => expect(canvas.style.width).toBe(`${CSS_WIDTH_AT_ZOOM_1}px`));
+  return { ...utils, page, canvas };
+}
+
+function lastRenderScale(): number {
+  const calls = pdfjsMocks.getViewport.mock.calls;
+  return calls[calls.length - 1][0].scale;
+}
+
+// A wheel event with ctrlKey, as Ctrl+wheel and a trackpad pinch both arrive.
+function wheelWithCtrl(target: Element, deltaY: number) {
+  return fireEvent.wheel(target, { deltaY, ctrlKey: true, clientX: 30, clientY: 40 });
+}
+
 describe('PdfPreviewModal', () => {
   beforeEach(() => {
     vi.clearAllMocks();
+    // clearAllMocks keeps implementations, but a test that swaps the page
+    // geometry would otherwise leak it into every test after it.
+    pdfjsMocks.getViewport.mockImplementation(pdfjsMocks.defaultViewport);
     vi.stubGlobal(
       'fetch',
       vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 })),
     );
+    // The setup-file stub is wiped by unstubAllGlobals below; keep one here.
+    vi.stubGlobal(
+      'ResizeObserver',
+      class {
+        observe() {}
+        unobserve() {}
+        disconnect() {}
+      },
+    );
   });
 
   afterEach(() => {
@@ -92,6 +127,40 @@ describe('PdfPreviewModal', () => {
     expect(await screen.findByText('This file cannot be previewed.')).toBeInTheDocument();
   });
 
+  it('points pdf.js at the resources it fetches at runtime', async () => {
+    renderModal();
+    await screen.findByText('Page 1 of 3');
+
+    // Unset, CJK text has no CMaps, non-embedded fonts no font data, and the
+    // JPEG2000/JBIG2/ICC decoders no wasm — all of which fail silently.
+    expect(pdfjsMocks.getDocument).toHaveBeenCalledWith(
+      expect.objectContaining({
+        cMapUrl: '/assets/pdfjs/cmaps/',
+        iccUrl: '/assets/pdfjs/iccs/',
+        standardFontDataUrl: '/assets/pdfjs/standard_fonts/',
+        wasmUrl: '/assets/pdfjs/wasm/',
+      }),
+    );
+  });
+
+  it('destroys a loading task that resolved after the modal closed', async () => {
+    // Closing during the fetch/import window used to leave cleanup holding a
+    // null task, and the pdf.js worker it later started ran on forever.
+    let deliver: (response: Response) => void = () => {};
+    vi.stubGlobal(
+      'fetch',
+      vi.fn(() => new Promise<Response>((resolve) => (deliver = resolve))),
+    );
+    const destroy = vi.fn();
+    pdfjsMocks.getDocument.mockReturnValueOnce({ promise: new Promise(() => {}), destroy } as never);
+
+    const { unmount } = renderModal();
+    unmount();
+    deliver(new Response(new Uint8Array([1, 2, 3]), { status: 200 }));
+
+    await waitFor(() => expect(destroy).toHaveBeenCalledTimes(1));
+  });
+
   it('refuses oversized files without fetching them', async () => {
     const fetchSpy = vi.fn();
     vi.stubGlobal('fetch', fetchSpy);
@@ -100,4 +169,275 @@ describe('PdfPreviewModal', () => {
     expect(await screen.findByText(/too large to preview/)).toBeInTheDocument();
     expect(fetchSpy).not.toHaveBeenCalled();
   });
+
+  describe('zoom', () => {
+    it('zooms on Ctrl+wheel and keeps the browser from zooming the page', async () => {
+      const { page, canvas } = await renderLoadedModal();
+      const scaleBefore = lastRenderScale();
+
+      const notPrevented = wheelWithCtrl(page, -100);
+
+      expect(notPrevented).toBe(false);
+      // Immediate feedback: the raster is scaled by CSS by exactly one step.
+      expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 1.25, 5);
+      // Then the page is re-rasterised at the new scale once the wheel settles.
+      await waitFor(() => expect(lastRenderScale()).toBeCloseTo(scaleBefore * 1.25, 5));
+      expect(pdfjsMocks.render).toHaveBeenCalledTimes(2);
+    });
+
+    it('zooms out on Ctrl+wheel down', async () => {
+      const { page, canvas } = await renderLoadedModal();
+
+      wheelWithCtrl(page, 100);
+
+      expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 / 1.25, 5);
+    });
+
+    it('turns a pinch (many small ctrlKey deltas) into a smooth ramp', async () => {
+      const { page, canvas } = await renderLoadedModal();
+
+      for (let i = 0; i < 10; i++) wheelWithCtrl(page, -3);
+
+      const width = parseFloat(canvas.style.width);
+      expect(width).toBeGreaterThan(CSS_WIDTH_AT_ZOOM_1);
+      // Ten pixels of pinch is one wheel notch (30px clamp → one step), not ten.
+      expect(width).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 1.25, 5);
+    });
+
+    it('clamps to the zoom range', async () => {
+      const { page, canvas } = await renderLoadedModal();
+
+      for (let i = 0; i < 40; i++) wheelWithCtrl(page, -100);
+      expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 4, 5);
+
+      for (let i = 0; i < 40; i++) wheelWithCtrl(page, 100);
+      expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 0.4, 5);
+    });
+
+    it('zooms on a plain wheel while the page fits the viewport', async () => {
+      const { page, canvas } = await renderLoadedModal();
+      // jsdom reports every scroll metric as 0: nothing to scroll.
+      const notPrevented = fireEvent.wheel(page, { deltaY: -100 });
+
+      expect(notPrevented).toBe(false);
+      expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 1.25, 5);
+    });
+
+    it('lets a plain wheel scroll once the page overflows the viewport', async () => {
+      const { page, canvas } = await renderLoadedModal();
+      Object.defineProperty(page, 'scrollHeight', { configurable: true, value: 2000 });
+      Object.defineProperty(page, 'clientHeight', { configurable: true, value: 500 });
+
+      const notPrevented = fireEvent.wheel(page, { deltaY: -100 });
+
+      expect(notPrevented).toBe(true);
+      expect(canvas.style.width).toBe(`${CSS_WIDTH_AT_ZOOM_1}px`);
+    });
+
+    it('zooms with the keyboard: + and - step, 0 resets', async () => {
+      const { canvas } = await renderLoadedModal();
+
+      fireEvent.keyDown(window, { key: '+' });
+      expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 1.25, 5);
+
+      fireEvent.keyDown(window, { key: '-' });
+      fireEvent.keyDown(window, { key: '-' });
+      expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 / 1.25, 5);
+
+      fireEvent.keyDown(window, { key: '0' });
+      expect(canvas.style.width).toBe(`${CSS_WIDTH_AT_ZOOM_1}px`);
+    });
+
+    it('leaves Ctrl+plus / Ctrl+0 to the browser', async () => {
+      const { canvas } = await renderLoadedModal();
+
+      fireEvent.keyDown(window, { key: '+', ctrlKey: true });
+      fireEvent.keyDown(window, { key: '0', metaKey: true });
+
+      expect(canvas.style.width).toBe(`${CSS_WIDTH_AT_ZOOM_1}px`);
+    });
+
+    it('pinch-zooms with two touch pointers', async () => {
+      // jsdom has no PointerEvent, and testing-library then falls back to a
+      // plain Event without pointerId / pointerType.
+      class FakePointerEvent extends MouseEvent {
+        pointerId: number;
+        pointerType: string;
+        constructor(type: string, init: PointerEventInit = {}) {
+          super(type, init);
+          this.pointerId = init.pointerId ?? 0;
+          this.pointerType = init.pointerType ?? '';
+        }
+      }
+      Object.defineProperty(window, 'PointerEvent', { configurable: true, value: FakePointerEvent });
+      const { page, canvas } = await renderLoadedModal();
+
+      fireEvent.pointerDown(page, { pointerId: 1, pointerType: 'touch', clientX: 100, clientY: 100 });
+      fireEvent.pointerDown(page, { pointerId: 2, pointerType: 'touch', clientX: 200, clientY: 100 });
+      // First move sets the reference distance, the second one spreads it.
+      fireEvent.pointerMove(page, { pointerId: 2, pointerType: 'touch', clientX: 200, clientY: 100 });
+      fireEvent.pointerMove(page, { pointerId: 2, pointerType: 'touch', clientX: 300, clientY: 100 });
+
+      expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 2, 5);
+
+      fireEvent.pointerUp(page, { pointerId: 2, pointerType: 'touch' });
+      fireEvent.pointerUp(page, { pointerId: 1, pointerType: 'touch' });
+      delete (window as { PointerEvent?: unknown }).PointerEvent;
+    });
+
+    it('still offers the zoom buttons', async () => {
+      const user = userEvent.setup();
+      const { canvas } = await renderLoadedModal();
+
+      await user.click(screen.getByRole('button', { name: 'Zoom in' }));
+      expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 1.25, 5);
+
+      await user.click(screen.getByRole('button', { name: 'Zoom out' }));
+      expect(canvas.style.width).toBe(`${CSS_WIDTH_AT_ZOOM_1}px`);
+    });
+  });
+
+  describe('fullscreen', () => {
+    const requestFullscreen = vi.fn();
+    const exitFullscreen = vi.fn();
+
+    beforeEach(() => {
+      requestFullscreen.mockReset().mockResolvedValue(undefined);
+      exitFullscreen.mockReset().mockResolvedValue(undefined);
+      Object.defineProperty(document, 'fullscreenEnabled', { configurable: true, value: true });
+      Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: null, writable: true });
+      Object.defineProperty(document, 'exitFullscreen', { configurable: true, value: exitFullscreen });
+      Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', { configurable: true, value: requestFullscreen });
+    });
+
+    afterEach(() => {
+      delete (document as { fullscreenEnabled?: boolean }).fullscreenEnabled;
+      delete (document as { fullscreenElement?: Element | null }).fullscreenElement;
+      delete (document as { exitFullscreen?: () => Promise<void> }).exitFullscreen;
+      delete (HTMLElement.prototype as { requestFullscreen?: () => Promise<void> }).requestFullscreen;
+    });
+
+    // The browser flips fullscreenElement and fires fullscreenchange; the
+    // mocks do neither, so the test plays the browser's part.
+    function enterFullscreen(panel: Element) {
+      (document as { fullscreenElement: Element | null }).fullscreenElement = panel;
+      act(() => {
+        document.dispatchEvent(new Event('fullscreenchange'));
+      });
+    }
+
+    it('double-click on the page toggles fullscreen', async () => {
+      const { page } = await renderLoadedModal();
+      const panel = screen.getByText('drawing.pdf').closest('.flex-col') as HTMLElement;
+
+      fireEvent.doubleClick(page);
+      expect(requestFullscreen).toHaveBeenCalledTimes(1);
+      expect(requestFullscreen.mock.instances[0]).toBe(panel);
+
+      enterFullscreen(panel);
+      expect(screen.getByRole('button', { name: 'Exit fullscreen' })).toBeInTheDocument();
+
+      fireEvent.doubleClick(page);
+      expect(exitFullscreen).toHaveBeenCalledTimes(1);
+    });
+
+    it('offers a fullscreen button in the header', async () => {
+      const user = userEvent.setup();
+      await renderLoadedModal();
+
+      await user.click(screen.getByRole('button', { name: 'Fullscreen' }));
+      expect(requestFullscreen).toHaveBeenCalledTimes(1);
+    });
+
+    it('re-syncs when the browser leaves fullscreen on Esc', async () => {
+      const { page } = await renderLoadedModal();
+      const panel = screen.getByText('drawing.pdf').closest('.flex-col') as HTMLElement;
+
+      fireEvent.doubleClick(page);
+      enterFullscreen(panel);
+      expect(screen.getByRole('button', { name: 'Exit fullscreen' })).toBeInTheDocument();
+
+      // Esc while fullscreen is the browser's: the modal must not close.
+      fireEvent.keyDown(window, { key: 'Escape' });
+      expect(mockOnClose).not.toHaveBeenCalled();
+
+      enterFullscreen(null as unknown as Element);
+      expect(screen.getByRole('button', { name: 'Fullscreen' })).toBeInTheDocument();
+
+      fireEvent.keyDown(window, { key: 'Escape' });
+      expect(mockOnClose).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('grid thumbnail snapshot', () => {
+    // jsdom has no 2D backend: the snapshot helper draws into an offscreen
+    // canvas and hands the result to toBlob, so both have to be stood in for.
+    function stubCanvas2d(blob: Blob | null = new Blob(['png'], { type: 'image/png' })) {
+      const context = { fillStyle: '', fillRect: vi.fn(), drawImage: vi.fn() };
+      vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
+        context as unknown as CanvasRenderingContext2D,
+      );
+      vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation((callback) => callback(blob));
+      return context;
+    }
+
+    afterEach(() => {
+      vi.restoreAllMocks();
+    });
+
+    it('hands the first page to onSnapshot as a PNG', async () => {
+      stubCanvas2d();
+      const onSnapshot = vi.fn();
+
+      await renderLoadedModal({ onSnapshot });
+
+      await waitFor(() => expect(onSnapshot).toHaveBeenCalledTimes(1));
+      const blob = onSnapshot.mock.calls[0][0] as Blob;
+      expect(blob.type).toBe('image/png');
+    });
+
+    it('sends it once, not on every re-render of page 1', async () => {
+      stubCanvas2d();
+      const user = userEvent.setup();
+      const onSnapshot = vi.fn();
+      await renderLoadedModal({ onSnapshot });
+      await waitFor(() => expect(onSnapshot).toHaveBeenCalledTimes(1));
+
+      await user.click(screen.getByRole('button', { name: 'Next page' }));
+      await screen.findByText('Page 2 of 3');
+      await user.click(screen.getByRole('button', { name: 'Previous page' }));
+      await screen.findByText('Page 1 of 3');
+
+      expect(onSnapshot).toHaveBeenCalledTimes(1);
+    });
+
+    it('stays quiet when the canvas yields no blob', async () => {
+      stubCanvas2d(null);
+      const onSnapshot = vi.fn();
+
+      await renderLoadedModal({ onSnapshot });
+      await waitFor(() => expect(pdfjsMocks.render).toHaveBeenCalled());
+
+      expect(onSnapshot).not.toHaveBeenCalled();
+    });
+  });
+
+  it('keeps the raster inside the canvas area iOS Safari will back', async () => {
+    // Past roughly 16.7M pixels iOS Safari hands back a blank canvas instead
+    // of failing, so resolution is what gives way, not the page.
+    vi.stubGlobal('devicePixelRatio', 2);
+    pdfjsMocks.getViewport.mockImplementation(({ scale }: { scale: number }) => ({
+      width: 30000 * scale,
+      height: 30000 * scale,
+    }));
+
+    renderModal();
+    await screen.findByText('Page 1 of 3');
+    await waitFor(() => expect(pdfjsMocks.render).toHaveBeenCalled());
+
+    // Unclamped this would be 0.1 (fit floor) x 2 (dpr) = 6000 x 6000 px.
+    const scale = lastRenderScale();
+    expect(scale).toBeLessThan(0.2);
+    expect(30000 * scale * (30000 * scale)).toBeLessThanOrEqual(16 * 1024 * 1024);
+  });
 });

+ 140 - 0
frontend/src/__tests__/components/PreviewModalShell.test.tsx

@@ -0,0 +1,140 @@
+/**
+ * Tests for PreviewModalShell (#2976) — the backdrop, panel size and header
+ * every file preview shares.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { PreviewModalShell } from '../../components/PreviewModalShell';
+import { usePreviewFullscreen } from '../../hooks/usePreviewFullscreen';
+
+const mockOnClose = vi.fn();
+
+function Harness({ closeOnBackdropClick }: { closeOnBackdropClick?: boolean }) {
+  const fullscreen = usePreviewFullscreen();
+  return (
+    <PreviewModalShell
+      title="drawing.pdf"
+      fullscreen={fullscreen}
+      onClose={mockOnClose}
+      closeOnBackdropClick={closeOnBackdropClick}
+      actions={<button type="button">Zoom in</button>}
+    >
+      <div data-testid="preview-body" onDoubleClick={fullscreen.toggleFullscreen}>
+        body
+      </div>
+    </PreviewModalShell>
+  );
+}
+
+function panelOf(): HTMLElement {
+  return screen.getByText('drawing.pdf').closest('.flex-col') as HTMLElement;
+}
+
+describe('PreviewModalShell', () => {
+  const requestFullscreen = vi.fn();
+  const exitFullscreen = vi.fn();
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    requestFullscreen.mockResolvedValue(undefined);
+    exitFullscreen.mockResolvedValue(undefined);
+    Object.defineProperty(document, 'fullscreenEnabled', { configurable: true, value: true });
+    Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: null, writable: true });
+    Object.defineProperty(document, 'exitFullscreen', { configurable: true, value: exitFullscreen });
+    Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', { configurable: true, value: requestFullscreen });
+  });
+
+  afterEach(() => {
+    delete (document as { fullscreenEnabled?: boolean }).fullscreenEnabled;
+    delete (document as { fullscreenElement?: Element | null }).fullscreenElement;
+    delete (document as { exitFullscreen?: () => Promise<void> }).exitFullscreen;
+    delete (HTMLElement.prototype as { requestFullscreen?: () => Promise<void> }).requestFullscreen;
+  });
+
+  // The browser flips fullscreenElement and fires fullscreenchange; the mocks
+  // do neither, so the test plays the browser's part.
+  function browserEntersFullscreen(panel: Element | null) {
+    (document as { fullscreenElement: Element | null }).fullscreenElement = panel;
+    act(() => {
+      document.dispatchEvent(new Event('fullscreenchange'));
+    });
+  }
+
+  it('gives the panel the size every preview shares', () => {
+    render(<Harness />);
+    const panel = panelOf();
+
+    expect(panel.className).toContain('w-[min(1800px,96vw)]');
+    expect(panel.className).toContain('h-[94vh]');
+    expect(panel.className).toContain('rounded-lg');
+  });
+
+  it('renders the title, the preview actions and the close button', async () => {
+    const user = userEvent.setup();
+    render(<Harness />);
+
+    expect(screen.getByText('drawing.pdf')).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: 'Zoom in' })).toBeInTheDocument();
+
+    await user.click(screen.getByRole('button', { name: 'Close' }));
+    expect(mockOnClose).toHaveBeenCalledTimes(1);
+  });
+
+  it('goes fullscreen on a double-click in the body and fills the screen', () => {
+    render(<Harness />);
+    const panel = panelOf();
+
+    fireEvent.doubleClick(screen.getByTestId('preview-body'));
+    expect(requestFullscreen).toHaveBeenCalledTimes(1);
+    expect(requestFullscreen.mock.instances[0]).toBe(panel);
+
+    browserEntersFullscreen(panel);
+    expect(panel.className).toContain('max-w-none');
+    expect(panel.className).not.toContain('w-[min(1800px,96vw)]');
+    expect(screen.getByRole('button', { name: 'Exit fullscreen' })).toBeInTheDocument();
+  });
+
+  it('toggles fullscreen from the header button', async () => {
+    const user = userEvent.setup();
+    render(<Harness />);
+
+    await user.click(screen.getByRole('button', { name: 'Fullscreen' }));
+    expect(requestFullscreen).toHaveBeenCalledTimes(1);
+
+    browserEntersFullscreen(panelOf());
+    await user.click(screen.getByRole('button', { name: 'Exit fullscreen' }));
+    expect(exitFullscreen).toHaveBeenCalledTimes(1);
+  });
+
+  it('closes on Escape, but leaves it to the browser while fullscreen', () => {
+    render(<Harness />);
+
+    browserEntersFullscreen(panelOf());
+    fireEvent.keyDown(window, { key: 'Escape' });
+    expect(mockOnClose).not.toHaveBeenCalled();
+
+    browserEntersFullscreen(null);
+    fireEvent.keyDown(window, { key: 'Escape' });
+    expect(mockOnClose).toHaveBeenCalledTimes(1);
+  });
+
+  it('closes on a backdrop click only where the preview asks for it', async () => {
+    const user = userEvent.setup();
+    const { unmount } = render(<Harness />);
+    const backdrop = panelOf().parentElement as HTMLElement;
+
+    await user.click(backdrop);
+    expect(mockOnClose).not.toHaveBeenCalled();
+    unmount();
+
+    render(<Harness closeOnBackdropClick />);
+    await user.click(panelOf().parentElement as HTMLElement);
+    expect(mockOnClose).toHaveBeenCalledTimes(1);
+
+    // A click inside the panel is not a backdrop click.
+    await user.click(screen.getByTestId('preview-body'));
+    expect(mockOnClose).toHaveBeenCalledTimes(1);
+  });
+});

+ 125 - 1
frontend/src/__tests__/components/SpreadsheetPreviewModal.test.tsx

@@ -7,7 +7,7 @@
  */
 
 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { render, screen } from '@testing-library/react';
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import * as XLSX from 'xlsx';
 import { SpreadsheetPreviewModal } from '../../components/SpreadsheetPreviewModal';
@@ -111,4 +111,128 @@ describe('SpreadsheetPreviewModal', () => {
     expect(await screen.findByText(/too large to preview/)).toBeInTheDocument();
     expect(fetchSpy).not.toHaveBeenCalled();
   });
+
+  describe('fullscreen', () => {
+    const requestFullscreen = vi.fn();
+    const exitFullscreen = vi.fn();
+
+    beforeEach(() => {
+      requestFullscreen.mockReset().mockResolvedValue(undefined);
+      exitFullscreen.mockReset().mockResolvedValue(undefined);
+      Object.defineProperty(document, 'fullscreenEnabled', { configurable: true, value: true });
+      Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: null, writable: true });
+      Object.defineProperty(document, 'exitFullscreen', { configurable: true, value: exitFullscreen });
+      Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', { configurable: true, value: requestFullscreen });
+    });
+
+    afterEach(() => {
+      delete (document as { fullscreenEnabled?: boolean }).fullscreenEnabled;
+      delete (document as { fullscreenElement?: Element | null }).fullscreenElement;
+      delete (document as { exitFullscreen?: () => Promise<void> }).exitFullscreen;
+      delete (HTMLElement.prototype as { requestFullscreen?: () => Promise<void> }).requestFullscreen;
+    });
+
+    it('double-click on the sheet toggles fullscreen', async () => {
+      stubFetchWith('Article,Qty\nM3 screw,12\n');
+      renderModal();
+      await screen.findByText('M3 screw');
+      const content = screen.getByTestId('spreadsheet-preview-content');
+      const panel = screen.getByText('parts.csv').closest('.flex-col') as HTMLElement;
+
+      fireEvent.doubleClick(content);
+      expect(requestFullscreen).toHaveBeenCalledTimes(1);
+      expect(requestFullscreen.mock.instances[0]).toBe(panel);
+
+      // The mocks do not flip fullscreenElement or fire fullscreenchange;
+      // play the browser's part.
+      (document as { fullscreenElement: Element | null }).fullscreenElement = panel;
+      act(() => {
+        document.dispatchEvent(new Event('fullscreenchange'));
+      });
+      expect(screen.getByRole('button', { name: 'Exit fullscreen' })).toBeInTheDocument();
+
+      fireEvent.doubleClick(content);
+      expect(exitFullscreen).toHaveBeenCalledTimes(1);
+    });
+
+    it('offers a fullscreen button in the header', async () => {
+      stubFetchWith('Article,Qty\nM3 screw,12\n');
+      const user = userEvent.setup();
+      renderModal();
+      await screen.findByText('M3 screw');
+
+      await user.click(screen.getByRole('button', { name: 'Fullscreen' }));
+      expect(requestFullscreen).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('grid thumbnail snapshot', () => {
+    // The snapshot is drawn into an offscreen canvas, which jsdom cannot
+    // back: both the 2D context and toBlob are stood in for.
+    function stubCanvas2d(blob: Blob | null = new Blob(['png'], { type: 'image/png' })) {
+      const context = {
+        fillStyle: '',
+        strokeStyle: '',
+        lineWidth: 0,
+        font: '',
+        textBaseline: '',
+        fillRect: vi.fn(),
+        beginPath: vi.fn(),
+        moveTo: vi.fn(),
+        lineTo: vi.fn(),
+        stroke: vi.fn(),
+        fillText: vi.fn(),
+      };
+      vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
+        context as unknown as CanvasRenderingContext2D,
+      );
+      vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation((callback) => callback(blob));
+      return context;
+    }
+
+    afterEach(() => {
+      vi.restoreAllMocks();
+    });
+
+    it('hands the first sheet to onSnapshot as a PNG', async () => {
+      const context = stubCanvas2d();
+      stubFetchWith('Article,Qty\nM3 screw,12\n');
+      const onSnapshot = vi.fn();
+
+      renderModal({ onSnapshot });
+      await screen.findByText('M3 screw');
+
+      await waitFor(() => expect(onSnapshot).toHaveBeenCalledTimes(1));
+      expect((onSnapshot.mock.calls[0][0] as Blob).type).toBe('image/png');
+      // The mini table is what makes it recognisable in the grid.
+      expect(context.fillText).toHaveBeenCalledWith('M3 screw', expect.any(Number), expect.any(Number), expect.any(Number));
+    });
+
+    it('draws the first sheet that has rows, not an empty leading one', async () => {
+      stubCanvas2d();
+      const workbook = XLSX.utils.book_new();
+      XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([]), 'Cover');
+      XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([['Part', 'Qty'], ['Hinge', '2']]), 'Bom');
+      stubFetchWith(XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer);
+      const onSnapshot = vi.fn();
+
+      renderModal({ onSnapshot, filename: 'bom.xlsx', fileType: 'xlsx' });
+      // The empty Cover sheet opens first, so the tab strip is what says the
+      // workbook has parsed.
+      await screen.findByRole('button', { name: 'Bom' });
+
+      await waitFor(() => expect(onSnapshot).toHaveBeenCalledTimes(1));
+    });
+
+    it('stays quiet for a sheet with no rows at all', async () => {
+      stubCanvas2d();
+      stubFetchWith('');
+      const onSnapshot = vi.fn();
+
+      renderModal({ onSnapshot });
+      await screen.findByText('This sheet is empty');
+
+      expect(onSnapshot).not.toHaveBeenCalled();
+    });
+  });
 });

+ 127 - 0
frontend/src/__tests__/hooks/useElementFullscreen.test.tsx

@@ -0,0 +1,127 @@
+/**
+ * useElementFullscreen (#2976): Fullscreen API where the browser grants it,
+ * the viewport-filling fallback everywhere else.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { useRef } from 'react';
+import { useElementFullscreen } from '../../hooks/useElementFullscreen';
+
+function Panel() {
+  const ref = useRef<HTMLDivElement>(null);
+  const { isFullscreen, toggleFullscreen, apiAvailable } = useElementFullscreen(ref);
+  return (
+    <div ref={ref} data-testid="panel" data-fullscreen={String(isFullscreen)} data-api={String(apiAvailable)}>
+      <button onClick={toggleFullscreen}>toggle</button>
+    </div>
+  );
+}
+
+const requestFullscreen = vi.fn();
+const exitFullscreen = vi.fn();
+
+function installApi() {
+  Object.defineProperty(document, 'fullscreenEnabled', { configurable: true, value: true });
+  Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: null, writable: true });
+  Object.defineProperty(document, 'exitFullscreen', { configurable: true, value: exitFullscreen });
+  Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', { configurable: true, value: requestFullscreen });
+}
+
+function removeApi() {
+  delete (document as { fullscreenEnabled?: boolean }).fullscreenEnabled;
+  delete (document as { fullscreenElement?: Element | null }).fullscreenElement;
+  delete (document as { exitFullscreen?: () => Promise<void> }).exitFullscreen;
+  delete (HTMLElement.prototype as { requestFullscreen?: () => Promise<void> }).requestFullscreen;
+}
+
+// The mocks neither flip fullscreenElement nor fire fullscreenchange, so the
+// tests play the browser's part.
+function browserSetsFullscreen(element: Element | null) {
+  (document as { fullscreenElement: Element | null }).fullscreenElement = element;
+  act(() => {
+    document.dispatchEvent(new Event('fullscreenchange'));
+  });
+}
+
+describe('useElementFullscreen', () => {
+  beforeEach(() => {
+    requestFullscreen.mockReset().mockResolvedValue(undefined);
+    exitFullscreen.mockReset().mockResolvedValue(undefined);
+  });
+
+  afterEach(() => {
+    removeApi();
+  });
+
+  it('requests fullscreen on the element and follows fullscreenchange', async () => {
+    installApi();
+    render(<Panel />);
+    const panel = screen.getByTestId('panel');
+    expect(panel.dataset.api).toBe('true');
+
+    fireEvent.click(screen.getByText('toggle'));
+    expect(requestFullscreen).toHaveBeenCalledTimes(1);
+    expect(requestFullscreen.mock.instances[0]).toBe(panel);
+    // Nothing changes until the browser confirms.
+    expect(panel.dataset.fullscreen).toBe('false');
+
+    browserSetsFullscreen(panel);
+    expect(panel.dataset.fullscreen).toBe('true');
+
+    fireEvent.click(screen.getByText('toggle'));
+    expect(exitFullscreen).toHaveBeenCalledTimes(1);
+
+    browserSetsFullscreen(null);
+    expect(panel.dataset.fullscreen).toBe('false');
+  });
+
+  it('ignores another element going fullscreen', () => {
+    installApi();
+    render(<Panel />);
+    const panel = screen.getByTestId('panel');
+
+    browserSetsFullscreen(document.createElement('video'));
+    expect(panel.dataset.fullscreen).toBe('false');
+  });
+
+  it('falls back to the viewport-filling layout when the request is refused', async () => {
+    installApi();
+    requestFullscreen.mockRejectedValue(new TypeError('Permissions check failed'));
+    render(<Panel />);
+    const panel = screen.getByTestId('panel');
+
+    await act(async () => {
+      fireEvent.click(screen.getByText('toggle'));
+    });
+    expect(panel.dataset.fullscreen).toBe('true');
+
+    // Leaving the fallback never calls the API.
+    fireEvent.click(screen.getByText('toggle'));
+    expect(panel.dataset.fullscreen).toBe('false');
+    expect(exitFullscreen).not.toHaveBeenCalled();
+  });
+
+  it('falls back where the Fullscreen API is missing', () => {
+    render(<Panel />);
+    const panel = screen.getByTestId('panel');
+    expect(panel.dataset.api).toBe('false');
+
+    fireEvent.click(screen.getByText('toggle'));
+    expect(panel.dataset.fullscreen).toBe('true');
+    fireEvent.click(screen.getByText('toggle'));
+    expect(panel.dataset.fullscreen).toBe('false');
+  });
+
+  it('leaves fullscreen when the panel unmounts while fullscreen', () => {
+    installApi();
+    const { unmount } = render(<Panel />);
+    const panel = screen.getByTestId('panel');
+
+    fireEvent.click(screen.getByText('toggle'));
+    browserSetsFullscreen(panel);
+
+    unmount();
+    expect(exitFullscreen).toHaveBeenCalledTimes(1);
+  });
+});

+ 80 - 0
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -600,6 +600,86 @@ describe('FileManagerPage', () => {
         expect(screen.getAllByText('STL').length).toBeGreaterThan(0);
       });
     });
+
+    // Since #2976 the server renders PDF thumbnails too, so the per-file
+    // action is offered for PDFs and stays hidden for types it cannot render.
+    describe('per-file action for PDFs', () => {
+      const pdfFile = {
+        id: 40,
+        filename: 'drawing.pdf',
+        file_path: '/library/drawing.pdf',
+        file_size: 4096,
+        file_type: 'pdf',
+        folder_id: null,
+        thumbnail_path: null,
+        print_name: null,
+        print_time_seconds: null,
+        print_count: 0,
+        duplicate_count: 0,
+        created_at: '2024-01-04T00:00:00Z',
+      };
+      const stepFile = { ...pdfFile, id: 41, filename: 'part.step', file_path: '/library/part.step', file_type: 'step' };
+
+      beforeEach(() => {
+        server.use(
+          http.get('/api/v1/library/files', () => HttpResponse.json([...mockFiles, pdfFile, stepFile])),
+        );
+      });
+
+      const openMenu = async (user: ReturnType<typeof userEvent.setup>, filename: string) => {
+        const card = screen.getByText(filename).closest('.group') as HTMLElement;
+        const kebab = card.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
+        await user.click(kebab);
+        return card;
+      };
+
+      it('offers Generate Thumbnail in the card menu of a PDF', async () => {
+        const user = userEvent.setup();
+        server.use(
+          http.post('/api/v1/library/generate-stl-thumbnails', async ({ request }) => {
+            const body = (await request.json()) as { file_ids?: number[] };
+            return HttpResponse.json({
+              processed: 1,
+              succeeded: 1,
+              failed: 0,
+              results: [{ file_id: body.file_ids?.[0], success: true }],
+            });
+          })
+        );
+        render(<FileManagerPage />);
+        await waitFor(() => expect(screen.getByText('drawing.pdf')).toBeInTheDocument());
+
+        const card = await openMenu(user, 'drawing.pdf');
+        await user.click(within(card).getByText('Generate Thumbnail'));
+
+        expect(await screen.findByText('Thumbnail generated')).toBeInTheDocument();
+      });
+
+      it('does not offer it for STEP, which only the browser can render', async () => {
+        const user = userEvent.setup();
+        render(<FileManagerPage />);
+        await waitFor(() => expect(screen.getByText('part.step')).toBeInTheDocument());
+
+        const card = await openMenu(user, 'part.step');
+        expect(within(card).queryByText('Generate Thumbnail')).not.toBeInTheDocument();
+      });
+
+      it('offers the action in the list view strip of a PDF', async () => {
+        const user = userEvent.setup();
+        render(<FileManagerPage />);
+        await waitFor(() => expect(screen.getByText('drawing.pdf')).toBeInTheDocument());
+
+        await user.click(screen.getByRole('button', { name: /list/i }));
+
+        // List rows are CSS grids; the row is the nearest grid ancestor.
+        await waitFor(() => {
+          const row = screen.getByText('drawing.pdf').closest('.grid') as HTMLElement;
+          expect(within(row).getByTitle('Generate Thumbnail')).toBeInTheDocument();
+        });
+        const stepRow = screen.getByText('part.step').closest('.grid') as HTMLElement;
+        expect(within(stepRow).queryByTitle('Generate Thumbnail')).not.toBeInTheDocument();
+      });
+    });
   });
 
   describe('upload modal (FileUploadModal)', () => {

+ 335 - 0
frontend/src/__tests__/pages/FileManagerPreviewOpening.test.tsx

@@ -0,0 +1,335 @@
+/**
+ * How a preview is opened from the File Manager (#2976): double-click in the
+ * grid and the list, the toolbar's Preview button, and the per-file menu.
+ *
+ * The preview modals themselves are stubbed — pdf.js, SheetJS and three.js
+ * have their own tests and none of them render in jsdom.
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { fireEvent, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { FileManagerPage } from '../../pages/FileManagerPage';
+import { server } from '../mocks/server';
+
+const mockNavigate = vi.fn();
+vi.mock('react-router-dom', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('react-router-dom')>()),
+  useNavigate: () => mockNavigate,
+}));
+
+vi.mock('../../components/ModelViewerModal', () => ({
+  ModelViewerModal: ({ title }: { title: string }) => <div data-testid="model-viewer-modal">{title}</div>,
+}));
+vi.mock('../../components/PdfPreviewModal', () => ({
+  PdfPreviewModal: ({ filename }: { filename: string }) => <div data-testid="pdf-preview-modal">{filename}</div>,
+}));
+vi.mock('../../components/SpreadsheetPreviewModal', () => ({
+  SpreadsheetPreviewModal: ({ filename }: { filename: string }) => (
+    <div data-testid="sheet-preview-modal">{filename}</div>
+  ),
+}));
+vi.mock('../../components/ImagePreviewModal', () => ({
+  ImagePreviewModal: ({ filename }: { filename: string }) => <div data-testid="image-preview-modal">{filename}</div>,
+}));
+vi.mock('../../components/LibraryFileDetailsModal', () => ({
+  LibraryFileDetailsModal: () => <div data-testid="details-modal" />,
+}));
+
+function libraryFile(overrides: Record<string, unknown>) {
+  return {
+    file_path: '/library/file',
+    file_size: 4096,
+    folder_id: null,
+    thumbnail_path: null,
+    print_name: null,
+    print_time_seconds: null,
+    print_count: 0,
+    duplicate_count: 0,
+    created_at: '2026-01-01T00:00:00Z',
+    ...overrides,
+  };
+}
+
+const mockFiles = [
+  libraryFile({ id: 1, filename: 'benchy.gcode.3mf', file_type: 'gcode.3mf' }),
+  libraryFile({ id: 2, filename: 'bracket.stl', file_type: 'stl' }),
+  libraryFile({ id: 3, filename: 'drawing.pdf', file_type: 'pdf' }),
+  libraryFile({ id: 4, filename: 'parts.csv', file_type: 'csv' }),
+  libraryFile({
+    id: 5,
+    filename: 'photo.png',
+    file_type: 'png',
+    tags: [{ id: 21, name: 'reference', color: '#00ae42' }],
+    has_notes: true,
+  }),
+  libraryFile({ id: 6, filename: 'notes.md', file_type: 'md' }),
+  libraryFile({ id: 7, filename: 'scan.tif', file_type: 'tif' }),
+];
+
+function card(name: string): HTMLElement {
+  return screen.getByText(name).closest('div.group') as HTMLElement;
+}
+
+function row(name: string): HTMLElement {
+  return screen.getByText(name).closest('div[class*="grid-cols-"]') as HTMLElement;
+}
+
+/**
+ * The preview modals load lazily, so one opened by mistake renders a moment
+ * after the event that opened it: a few hundred milliseconds for the first
+ * one in a run. Absence only counts once it has had findBy's full second to
+ * appear; checking straight away passed whenever no earlier test had loaded
+ * that modal yet.
+ */
+async function expectNoPreviewOpened() {
+  await expect(screen.findByTestId(/^(model-viewer|pdf-preview|sheet-preview|image-preview)-modal$/)).rejects.toThrow();
+}
+
+describe('FileManagerPage preview opening', () => {
+  beforeEach(() => {
+    mockNavigate.mockClear();
+    // localStorage is a module-global vi.fn mock (see __tests__/setup.ts), so
+    // the view mode is programmed rather than written.
+    (localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValue(null);
+    server.use(
+      http.get('/api/v1/library/folders', () => HttpResponse.json([])),
+      http.get('/api/v1/library/files', () => HttpResponse.json(mockFiles)),
+      http.get('/api/v1/library/stats', () =>
+        HttpResponse.json({
+          total_files: mockFiles.length,
+          total_folders: 0,
+          total_size_bytes: 1024,
+          disk_free_bytes: 1024 * 1024,
+          disk_total_bytes: 2048 * 1024,
+        }),
+      ),
+      http.get('/api/v1/settings/', () => HttpResponse.json({ check_updates: false })),
+      http.get('/api/v1/projects/', () => HttpResponse.json([])),
+      http.get('/api/v1/archives/', () => HttpResponse.json([])),
+    );
+  });
+
+  describe('double-click in the grid', () => {
+    it('opens the image preview for a PNG and leaves the selection alone', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('photo.png');
+
+      await user.dblClick(card('photo.png'));
+
+      expect(await screen.findByTestId('image-preview-modal')).toHaveTextContent('photo.png');
+      // The two clicks of a double-click toggle the selection twice.
+      expect(screen.queryByText('1 selected')).not.toBeInTheDocument();
+    });
+
+    it('opens the 3D viewer for an STL', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('bracket.stl');
+
+      await user.dblClick(card('bracket.stl'));
+
+      expect(await screen.findByTestId('model-viewer-modal')).toHaveTextContent('bracket.stl');
+    });
+
+    it('sends a sliced file to the gcode viewer route', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('benchy.gcode.3mf');
+
+      await user.dblClick(card('benchy.gcode.3mf'));
+
+      expect(mockNavigate).toHaveBeenCalledWith('/gcode-viewer?library_file=1');
+    });
+
+    it('does nothing for a file with no preview', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('notes.md');
+
+      await user.dblClick(card('notes.md'));
+
+      await expectNoPreviewOpened();
+      expect(mockNavigate).not.toHaveBeenCalled();
+    });
+
+    // The card's own controls are not "the row": stopping their click is not
+    // enough, because dblclick is a separate native event (#2976).
+    it('ignores a double-click on the card menu button', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('photo.png');
+
+      const imageCard = card('photo.png');
+      const kebab = imageCard.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
+      await user.dblClick(kebab);
+
+      await expectNoPreviewOpened();
+    });
+
+    // The chip's own click toggles the tag filter and re-renders the list, so
+    // the dblclick is fired directly: what is under test is whether it bubbles
+    // to the card, not what the two clicks before it did.
+    it('ignores a double-click on a tag chip', async () => {
+      render(<FileManagerPage />);
+      await screen.findByText('photo.png');
+
+      fireEvent.doubleClick(within(card('photo.png')).getByTitle('reference'));
+
+      await expectNoPreviewOpened();
+    });
+
+    // The notes and photos indicators open the file's details; a double-click
+    // on one must not open the preview on top of them.
+    it('ignores a double-click on the notes indicator', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('photo.png');
+
+      await user.dblClick(within(card('photo.png')).getByTitle('Has notes'));
+
+      expect(await screen.findByTestId('details-modal')).toBeInTheDocument();
+      await expectNoPreviewOpened();
+    });
+  });
+
+  describe('the list view', () => {
+    beforeEach(() => {
+      (localStorage.getItem as ReturnType<typeof vi.fn>).mockImplementation((key: string) =>
+        key === 'library-view-mode' ? 'list' : null,
+      );
+    });
+
+    it('opens the document preview for a PDF', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('drawing.pdf');
+
+      await user.dblClick(row('drawing.pdf'));
+
+      expect(await screen.findByTestId('pdf-preview-modal')).toHaveTextContent('drawing.pdf');
+    });
+
+    it('opens the spreadsheet preview for a CSV', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('parts.csv');
+
+      await user.dblClick(row('parts.csv'));
+
+      expect(await screen.findByTestId('sheet-preview-modal')).toHaveTextContent('parts.csv');
+    });
+
+    it('offers an image file the same action-strip preview button as a document', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('photo.png');
+
+      await user.click(within(row('photo.png')).getByTitle('Preview'));
+
+      expect(await screen.findByTestId('image-preview-modal')).toHaveTextContent('photo.png');
+    });
+
+    it('ignores a double-click on the row action strip', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('photo.png');
+
+      // An impatient double-tap on Rename must not also open the preview.
+      await user.dblClick(within(row('photo.png')).getByTitle('Rename'));
+
+      await expectNoPreviewOpened();
+    });
+
+    it('ignores a double-click on the row tag cell', async () => {
+      render(<FileManagerPage />);
+      await screen.findByText('photo.png');
+
+      fireEvent.doubleClick(within(row('photo.png')).getByTitle('reference'));
+
+      await expectNoPreviewOpened();
+    });
+  });
+
+  describe('the toolbar Preview button', () => {
+    it('appears for a single previewable selection and opens the preview', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('photo.png');
+
+      await user.click(card('photo.png'));
+      const preview = await screen.findByRole('button', { name: 'Preview' });
+      await user.click(preview);
+
+      expect(await screen.findByTestId('image-preview-modal')).toHaveTextContent('photo.png');
+    });
+
+    it('stays away for a multi-file selection', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('photo.png');
+
+      await user.click(card('photo.png'));
+      expect(await screen.findByRole('button', { name: 'Preview' })).toBeInTheDocument();
+
+      await user.click(card('drawing.pdf'));
+      await waitFor(() => expect(screen.queryByRole('button', { name: 'Preview' })).not.toBeInTheDocument());
+    });
+
+    it('stays away for a file with no preview', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('notes.md');
+
+      await user.click(card('notes.md'));
+
+      expect(await screen.findByText('1 selected')).toBeInTheDocument();
+      expect(screen.queryByRole('button', { name: 'Preview' })).not.toBeInTheDocument();
+    });
+  });
+
+  // The server thumbnails TIFF (PIL), but an <img> only decodes it on Safari,
+  // so the preview is not offered rather than downloading 50 MB to fail (#2976).
+  describe('a TIFF file', () => {
+    it('gets no Preview entry in the card menu and no toolbar button', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('scan.tif');
+
+      await user.click(card('scan.tif'));
+      expect(await screen.findByText('1 selected')).toBeInTheDocument();
+      expect(screen.queryByRole('button', { name: 'Preview' })).not.toBeInTheDocument();
+
+      const kebab = card('scan.tif').querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
+      await user.click(kebab);
+      expect(within(card('scan.tif')).queryByText('Preview')).not.toBeInTheDocument();
+    });
+
+    it('does nothing on double-click', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('scan.tif');
+
+      await user.dblClick(card('scan.tif'));
+
+      await expectNoPreviewOpened();
+      expect(mockNavigate).not.toHaveBeenCalled();
+    });
+  });
+
+  it('offers an image file a Preview entry in the card menu', async () => {
+    const user = userEvent.setup();
+    render(<FileManagerPage />);
+    await screen.findByText('photo.png');
+
+    const imageCard = card('photo.png');
+    const kebab = imageCard.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
+    await user.click(kebab);
+    await user.click(within(imageCard).getByText('Preview'));
+
+    expect(await screen.findByTestId('image-preview-modal')).toHaveTextContent('photo.png');
+  });
+});

+ 306 - 0
frontend/src/components/ImagePreviewModal.tsx

@@ -0,0 +1,306 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Image as ImageIcon, Loader2, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
+import { api, getAuthToken } from '../api/client';
+import { formatFileSize } from '../utils/file';
+import { PreviewModalShell, previewIconButtonClass } from './PreviewModalShell';
+import { usePreviewFullscreen } from '../hooks/usePreviewFullscreen';
+
+// The whole file is fetched into memory before it is shown, so a multi-hundred
+// megabyte scan gets the same notice the other previews give instead of a tab
+// that stops responding (#2976).
+export const IMAGE_PREVIEW_MAX_BYTES = 50 * 1024 * 1024;
+
+const MIN_ZOOM = 0.4;
+// Higher than the PDF's ceiling: a photo or a scan is worth inspecting at the
+// pixel, and unlike a PDF page there is no sharper raster to fall back on.
+const MAX_ZOOM = 8;
+// One button press, one key press, one mouse-wheel notch.
+const ZOOM_STEP = 1.25;
+// A pinch arrives as many small ctrlKey wheel events, a mouse notch as one
+// ±100; clamping the delta before the exponent gives the notch one ZOOM_STEP
+// and the pinch a smooth ramp.
+const WHEEL_DELTA_CLAMP = 30;
+const WHEEL_ZOOM_RATE = Math.log(ZOOM_STEP) / WHEEL_DELTA_CLAMP;
+
+const clampZoom = (zoom: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom));
+
+interface Point {
+  x: number;
+  y: number;
+}
+
+const ORIGIN: Point = { x: 0, y: 0 };
+
+interface ImagePreviewModalProps {
+  libraryFileId: number;
+  filename: string;
+  fileSize: number;
+  onClose: () => void;
+}
+
+// deltaMode 1 is lines (Firefox mouse wheel), 2 is pages; both scaled to pixels.
+function wheelDeltaPixels(e: WheelEvent): number {
+  if (e.deltaMode === 1) return e.deltaY * 16;
+  if (e.deltaMode === 2) return e.deltaY * 100;
+  return e.deltaY;
+}
+
+export function ImagePreviewModal({ libraryFileId, filename, fileSize, onClose }: ImagePreviewModalProps) {
+  const { t } = useTranslation();
+  const fullscreen = usePreviewFullscreen();
+  const { isFullscreen, toggleFullscreen } = fullscreen;
+  const containerRef = useRef<HTMLDivElement>(null);
+  const imageRef = useRef<HTMLImageElement>(null);
+
+  const [src, setSrc] = useState<string | null>(null);
+  const [loaded, setLoaded] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+  // The image is laid out fitted to the panel; zoom and pan are a transform on
+  // top of that, so zoom 1 is always "fits the window".
+  const [zoom, setZoom] = useState(1);
+  const [offset, setOffset] = useState<Point>(ORIGIN);
+  const [dragging, setDragging] = useState(false);
+
+  const zoomRef = useRef(1);
+  const offsetRef = useRef<Point>(ORIGIN);
+  const pointersRef = useRef<Map<number, Point>>(new Map());
+  const pinchDistanceRef = useRef<number | null>(null);
+
+  // Panning past the edges would strand the image off-screen, so the offset is
+  // bounded by however much of it the zoom pushes outside the viewport.
+  const clampOffset = useCallback((next: Point, atZoom: number): Point => {
+    const container = containerRef.current;
+    const image = imageRef.current;
+    if (!container || !image) return next;
+    const maxX = Math.max(0, (image.offsetWidth * atZoom - container.clientWidth) / 2);
+    const maxY = Math.max(0, (image.offsetHeight * atZoom - container.clientHeight) / 2);
+    return {
+      x: Math.min(maxX, Math.max(-maxX, next.x)),
+      y: Math.min(maxY, Math.max(-maxY, next.y)),
+    };
+  }, []);
+
+  const setView = useCallback((nextZoom: number, nextOffset: Point) => {
+    zoomRef.current = nextZoom;
+    offsetRef.current = nextOffset;
+    setZoom(nextZoom);
+    setOffset(nextOffset);
+  }, []);
+
+  /** Zoom by `factor`, keeping the point under the pointer where it is. */
+  const zoomAt = useCallback(
+    (factor: number, clientX?: number, clientY?: number) => {
+      const current = zoomRef.current;
+      const next = clampZoom(current * factor);
+      if (next === current) return;
+      const container = containerRef.current;
+      const previous = offsetRef.current;
+      let nextOffset = { x: previous.x * (next / current), y: previous.y * (next / current) };
+      if (container) {
+        const rect = container.getBoundingClientRect();
+        // The transform grows the image around the container's centre, so an
+        // anchored point is expressed relative to that centre.
+        const ax = (clientX == null ? rect.left + rect.width / 2 : clientX) - (rect.left + rect.width / 2);
+        const ay = (clientY == null ? rect.top + rect.height / 2 : clientY) - (rect.top + rect.height / 2);
+        const k = next / current;
+        nextOffset = { x: ax - (ax - previous.x) * k, y: ay - (ay - previous.y) * k };
+      }
+      setView(next, clampOffset(nextOffset, next));
+    },
+    [clampOffset, setView]
+  );
+
+  const resetView = useCallback(() => setView(1, ORIGIN), [setView]);
+
+  useEffect(() => {
+    let cancelled = false;
+    let objectUrl: string | null = null;
+    setSrc(null);
+    setLoaded(false);
+    setError(null);
+    setView(1, ORIGIN);
+
+    if (fileSize > IMAGE_PREVIEW_MAX_BYTES) {
+      setError(t('fileManager.preview.tooLarge', { size: formatFileSize(fileSize) }));
+      return;
+    }
+
+    const headers: HeadersInit = {};
+    const token = getAuthToken();
+    if (token) headers['Authorization'] = `Bearer ${token}`;
+
+    (async () => {
+      // Fetched rather than pointed at with <img src>: the download endpoint
+      // wants the bearer token, which an <img> cannot send.
+      const res = await fetch(api.getLibraryFileDownloadUrl(libraryFileId), { headers });
+      if (!res.ok) throw new Error(`HTTP ${res.status}`);
+      const blob = await res.blob();
+      if (cancelled) return;
+      objectUrl = URL.createObjectURL(blob);
+      setSrc(objectUrl);
+    })().catch((err: unknown) => {
+      console.error('[image-preview] load failed', err);
+      if (!cancelled) setError(t('fileManager.preview.error'));
+    });
+
+    return () => {
+      cancelled = true;
+      if (objectUrl) URL.revokeObjectURL(objectUrl);
+    };
+  }, [libraryFileId, fileSize, t, setView]);
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      // Ctrl/⌘ combinations are the browser's own zoom; leave them alone.
+      if (e.ctrlKey || e.metaKey || e.altKey) return;
+      if (e.key === '+' || e.key === '=') {
+        e.preventDefault();
+        zoomAt(ZOOM_STEP);
+      } else if (e.key === '-') {
+        e.preventDefault();
+        zoomAt(1 / ZOOM_STEP);
+      } else if (e.key === '0') {
+        e.preventDefault();
+        resetView();
+      }
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [resetView, zoomAt]);
+
+  // Native listener: React registers wheel as passive, so preventDefault —
+  // which keeps Ctrl+wheel from zooming the whole page — would be ignored.
+  // Nothing scrolls here, so a plain wheel zooms as well.
+  useEffect(() => {
+    const container = containerRef.current;
+    if (!container) return;
+    const handleWheel = (e: WheelEvent) => {
+      e.preventDefault();
+      const delta = Math.max(-WHEEL_DELTA_CLAMP, Math.min(WHEEL_DELTA_CLAMP, wheelDeltaPixels(e)));
+      zoomAt(Math.exp(-delta * WHEEL_ZOOM_RATE), e.clientX, e.clientY);
+    };
+    container.addEventListener('wheel', handleWheel, { passive: false });
+    return () => container.removeEventListener('wheel', handleWheel);
+  }, [zoomAt, src]);
+
+  // One pointer drags the image once it is bigger than the window, two fingers
+  // pinch-zoom around their midpoint. `touch-action: none` on the container
+  // hands both gestures here instead of to the browser's scroll / page zoom.
+  const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
+    pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
+    pinchDistanceRef.current = null;
+    if (pointersRef.current.size === 1 && zoomRef.current > 1) {
+      e.currentTarget.setPointerCapture(e.pointerId);
+      setDragging(true);
+    }
+  };
+
+  const handlePointerEnd = (e: React.PointerEvent<HTMLDivElement>) => {
+    pointersRef.current.delete(e.pointerId);
+    pinchDistanceRef.current = null;
+    if (pointersRef.current.size === 0) setDragging(false);
+  };
+
+  const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
+    const pointers = pointersRef.current;
+    const previous = pointers.get(e.pointerId);
+    if (!previous) return;
+    // A button released outside this container never reaches onPointerUp (no
+    // capture is taken below zoom 1), and the stale entry would then pan the
+    // image under a bare cursor — or count as a second finger on a hybrid
+    // device, turning the next one-finger drag into a pinch (#2976).
+    if (e.pointerType === 'mouse' && e.buttons === 0) {
+      handlePointerEnd(e);
+      return;
+    }
+    pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
+
+    if (pointers.size === 1) {
+      if (zoomRef.current <= 1) return;
+      const moved = { x: offsetRef.current.x + (e.clientX - previous.x), y: offsetRef.current.y + (e.clientY - previous.y) };
+      setView(zoomRef.current, clampOffset(moved, zoomRef.current));
+      return;
+    }
+    if (pointers.size !== 2) return;
+    const [a, b] = Array.from(pointers.values());
+    const distance = Math.hypot(a.x - b.x, a.y - b.y);
+    const last = pinchDistanceRef.current;
+    pinchDistanceRef.current = distance;
+    if (last != null && last > 0 && distance > 0) {
+      zoomAt(distance / last, (a.x + b.x) / 2, (a.y + b.y) / 2);
+    }
+  };
+
+  const canPan = zoom > 1;
+
+  return (
+    <PreviewModalShell
+      title={filename}
+      fullscreen={fullscreen}
+      onClose={onClose}
+      icon={<ImageIcon className="w-5 h-5 text-bambu-green flex-shrink-0" />}
+      actions={
+        src ? (
+          <>
+            <button
+              onClick={() => zoomAt(1 / ZOOM_STEP)}
+              className={previewIconButtonClass}
+              aria-label={t('fileManager.preview.zoomOut')}
+            >
+              <ZoomOut className="w-4 h-4" />
+            </button>
+            <button
+              onClick={() => zoomAt(ZOOM_STEP)}
+              className={previewIconButtonClass}
+              aria-label={t('fileManager.preview.zoomIn')}
+            >
+              <ZoomIn className="w-4 h-4" />
+            </button>
+            <button onClick={resetView} className={previewIconButtonClass} aria-label={t('fileManager.preview.resetZoom')}>
+              <RotateCcw className="w-4 h-4" />
+            </button>
+          </>
+        ) : undefined
+      }
+    >
+      <div
+        ref={containerRef}
+        data-testid="image-preview-content"
+        className={`relative flex-1 min-h-0 overflow-hidden bg-bambu-dark flex items-center justify-center touch-none ${
+          isFullscreen ? '' : 'rounded-b-lg'
+        } ${canPan ? (dragging ? 'cursor-grabbing' : 'cursor-grab') : ''}`}
+        onDoubleClick={toggleFullscreen}
+        onPointerDown={handlePointerDown}
+        onPointerMove={handlePointerMove}
+        onPointerUp={handlePointerEnd}
+        onPointerCancel={handlePointerEnd}
+        onPointerLeave={handlePointerEnd}
+      >
+        {error ? (
+          <p className="text-bambu-gray text-center p-6">{error}</p>
+        ) : (
+          <>
+            {src && (
+              <img
+                ref={imageRef}
+                src={src}
+                alt={filename}
+                draggable={false}
+                onLoad={() => setLoaded(true)}
+                onError={() => setError(t('fileManager.preview.error'))}
+                style={{ transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})` }}
+                className="max-w-full max-h-full object-contain select-none"
+              />
+            )}
+            {!loaded && (
+              <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
+                <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+              </div>
+            )}
+          </>
+        )}
+      </div>
+    </PreviewModalShell>
+  );
+}

+ 49 - 11
frontend/src/components/ModelViewer.tsx

@@ -64,6 +64,9 @@ function parseStepInWorker(buffer: ArrayBuffer): Promise<StepWorkerMesh[]> {
   });
 }
 
+// How far the user may dolly out, as a multiple of the framed distance.
+const MAX_ZOOM_OUT = 8;
+
 /**
  * Frame the camera on a bounding box.
  *
@@ -78,6 +81,7 @@ function fitCameraToBox(
   camera: THREE.PerspectiveCamera,
   controls: OrbitControls,
   box: THREE.Box3,
+  plateDiagonal: number,
   padding = 1.15,
 ): void {
   const size = box.getSize(new THREE.Vector3());
@@ -92,10 +96,18 @@ function fitCameraToBox(
   // Keep the established three-quarter view; only the distance changes.
   const direction = new THREE.Vector3(0.7, 0.5, 0.7).normalize();
   camera.position.copy(center).addScaledVector(direction, distance);
+
+  // Zoom range relative to the framed distance. Without the upper bound the
+  // wheel dollied straight through the far plane: five or six notches out and
+  // the model, plate and grid all vanished, which reads as "zoom is broken".
+  controls.minDistance = Math.max(radius * 0.05, 0.05);
+  controls.maxDistance = distance * MAX_ZOOM_OUT;
+
   // Clip planes scaled to the subject, so a small model doesn't z-fight and a
-  // large one isn't sliced by the far plane.
+  // large one isn't sliced by the far plane. The far plane has to cover the
+  // whole dolly range plus the plate, whose diagonal dwarfs a small model.
   camera.near = Math.max(distance / 1000, 0.01);
-  camera.far = distance + radius * 4;
+  camera.far = controls.maxDistance + radius * 4 + plateDiagonal;
   camera.updateProjectionMatrix();
 
   controls.target.copy(center);
@@ -108,6 +120,11 @@ interface BuildVolume {
   z: number;
 }
 
+// Module-level so the default keeps its identity: an inline default object is
+// new on every render and, as an effect dependency, would rebuild the whole
+// scene each time the component re-renders.
+const DEFAULT_BUILD_VOLUME: BuildVolume = { x: 256, y: 256, z: 256 };
+
 interface ModelViewerProps {
   url: string;
   fileType?: string;
@@ -765,7 +782,7 @@ function buildStepGroup(meshes: StepMeshData[], filamentColors?: string[]): THRE
 export function ModelViewer({
   url,
   fileType,
-  buildVolume = { x: 256, y: 256, z: 256 },
+  buildVolume = DEFAULT_BUILD_VOLUME,
   filamentColors,
   selectedPlateId = null,
   className = '',
@@ -786,6 +803,9 @@ export function ModelViewer({
   const modelGroupRef = useRef<THREE.Group | null>(null);
   const plateRef = useRef<THREE.Mesh | null>(null);
   const gridRef = useRef<THREE.GridHelper | null>(null);
+  // The last framed bounds, so Reset returns to the framed view rather than
+  // a fixed camera pose that ignores where the model was placed.
+  const fitBoxRef = useRef<THREE.Box3 | null>(null);
   const [loading, setLoading] = useState(true);
   const [error, setError] = useState<string | null>(null);
   const [parsedData, setParsedData] = useState<Parsed3MFData | null>(null);
@@ -1023,6 +1043,10 @@ export function ModelViewer({
       renderer.setSize(w, h);
     };
     window.addEventListener('resize', handleResize);
+    // Entering or leaving fullscreen resizes the container too; the observer
+    // catches that, but only after layout — resizing on the event itself
+    // avoids one frame drawn at the old aspect.
+    document.addEventListener('fullscreenchange', handleResize);
     const resizeObserver = new ResizeObserver(() => {
       handleResize();
     });
@@ -1030,6 +1054,7 @@ export function ModelViewer({
 
     return () => {
       window.removeEventListener('resize', handleResize);
+      document.removeEventListener('fullscreenchange', handleResize);
       resizeObserver.disconnect();
       cancelAnimationFrame(animationId);
       controls.dispose();
@@ -1148,7 +1173,8 @@ export function ModelViewer({
     const finalBox = new THREE.Box3().setFromObject(group);
 
     // Adjust camera to fit model
-    fitCameraToBox(cameraRef.current, controlsRef.current, finalBox);
+    fitBoxRef.current = finalBox;
+    fitCameraToBox(cameraRef.current, controlsRef.current, finalBox, Math.hypot(buildVolume.x, buildVolume.y));
 
     setLoading(false);
 
@@ -1184,17 +1210,29 @@ export function ModelViewer({
   }, [parsedData, stlGeometry, stepMeshes, selectedPlateId, filamentColors, buildVolume]);
 
   const resetView = () => {
-    if (cameraRef.current && controlsRef.current) {
-      cameraRef.current.position.set(150, 150, 150);
-      controlsRef.current.target.set(0, 50, 0);
-      controlsRef.current.update();
+    const camera = cameraRef.current;
+    const controls = controlsRef.current;
+    if (!camera || !controls) return;
+    if (fitBoxRef.current) {
+      fitCameraToBox(camera, controls, fitBoxRef.current, Math.hypot(buildVolume.x, buildVolume.y));
+    } else {
+      camera.position.set(150, 150, 150);
+      controls.target.set(0, 50, 0);
+      controls.update();
     }
   };
 
+  // Dolly along the view axis, like the wheel. Scaling the camera position
+  // itself measured from the world origin, not from the orbit target at the
+  // plate centre, so each press also slid the model sideways.
   const zoom = (factor: number) => {
-    if (cameraRef.current) {
-      cameraRef.current.position.multiplyScalar(factor);
-    }
+    const camera = cameraRef.current;
+    const controls = controlsRef.current;
+    if (!camera || !controls) return;
+    const offset = camera.position.clone().sub(controls.target);
+    const distance = Math.min(controls.maxDistance, Math.max(controls.minDistance, offset.length() * factor));
+    camera.position.copy(controls.target).addScaledVector(offset.normalize(), distance);
+    controls.update();
   };
 
   return (

+ 299 - 318
frontend/src/components/ModelViewerModal.tsx

@@ -1,9 +1,11 @@
 import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useQuery } from '@tanstack/react-query';
-import { X, ExternalLink, Box, Cog, Loader2, Layers, Check, Maximize2, Minimize2, ChevronDown } from 'lucide-react';
+import { ExternalLink, Box, Cog, Loader2, Layers, Check, ChevronDown } from 'lucide-react';
 import { ModelViewer } from './ModelViewer';
 import { Button } from './Button';
+import { PreviewModalShell } from './PreviewModalShell';
+import { usePreviewFullscreen } from '../hooks/usePreviewFullscreen';
 import { api, withMediaToken } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 import { isApiSliceableFileType, isSliceableFileType, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
@@ -156,26 +158,20 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
   const [platesLoading, setPlatesLoading] = useState(false);
   const [selectedPlateId, setSelectedPlateId] = useState<number | null>(null);
   const [platePage, setPlatePage] = useState(0);
-  const [isFullscreen, setIsFullscreen] = useState(false);
   const [platePanelHeight, setPlatePanelHeight] = useState<number | null>(null);
   const [isDraggingDivider, setIsDraggingDivider] = useState(false);
   const [hasCustomSplit, setHasCustomSplit] = useState(false);
   const splitContainerRef = useRef<HTMLDivElement>(null);
   const platesPanelRef = useRef<HTMLDivElement>(null);
+  // Real fullscreen where the browser offers it, the viewport-filling layout
+  // below otherwise (#2976).
+  const fullscreen = usePreviewFullscreen();
+  const { isFullscreen, toggleFullscreen } = fullscreen;
   const dividerHeight = 10;
   const minPlateHeight = 160;
   const minViewerPx = 240;
   const minViewerRatio = 0.35;
 
-  // Close on Escape key
-  useEffect(() => {
-    const handleKeyDown = (e: KeyboardEvent) => {
-      if (e.key === 'Escape') onClose();
-    };
-    window.addEventListener('keydown', handleKeyDown);
-    return () => window.removeEventListener('keydown', handleKeyDown);
-  }, [onClose]);
-
   useEffect(() => {
     setLoading(true);
 
@@ -454,339 +450,324 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
   }));
 
   return (
-    <div
-      className={`fixed inset-0 bg-black/70 flex items-center justify-center z-50 ${isFullscreen ? 'p-0' : 'p-8'}`}
-      onClick={onClose}
-    >
-      <div
-        className={`bg-bambu-dark-secondary border border-bambu-dark-tertiary w-full flex flex-col ${
-          isFullscreen ? 'h-full max-w-none rounded-none' : 'h-[80vh] max-w-4xl rounded-xl'
-        }`}
-        onClick={(e) => e.stopPropagation()}
-      >
-        {/* Header */}
-        <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
-          <div className="flex items-center gap-3 min-w-0 flex-1 mr-4">
-            <h2 className="text-lg font-semibold text-white truncate">{title}</h2>
-            {hasObjectCount && (
-              <span className="text-xs text-bambu-gray bg-bambu-dark-tertiary/70 px-2 py-1 rounded whitespace-nowrap">
-                {objectCountLabel}: {t('modelViewer.objectCount', { count: selectedObjectCount })}
-              </span>
-            )}
-          </div>
-          <div className="flex items-center gap-2">
-            {useBambuddySlicer ? (
-              <SlicerSplitButton
-                icon={<Cog className="w-4 h-4" />}
-                label={t('slice.action')}
-                dropdownLabel={t('modelViewer.moreSlicerOptions')}
-                onPrimary={() => onSliceWithBambuddy?.()}
-                items={slicerDropdownItems}
-              />
-            ) : canOpenInSlicer ? (
-              <SlicerSplitButton
-                icon={<ExternalLink className="w-4 h-4" />}
-                // Name the slicer when it is not the configured one. That happens
-                // when the configured slicer cannot take this format — an STL with
-                // Bambu Studio selected — and silently handing the file to the
-                // other one without saying so would be worse than the failure it
-                // replaces.
-                label={
-                  usableSlicers[0] === preferredSlicer
-                    ? t('modelViewer.openInSlicer')
-                    : t('modelViewer.openInSlicerWith', { slicer: slicerName(usableSlicers[0]) })
-                }
-                dropdownLabel={t('modelViewer.moreSlicerOptions')}
-                onPrimary={() => handleOpenInSlicer(usableSlicers[0])}
-                items={slicerDropdownItems}
-              />
-            ) : (
-              <Button variant="secondary" size="sm" disabled>
-                <ExternalLink className="w-4 h-4" />
-                {t('modelViewer.openInSlicer')}
-              </Button>
-            )}
-            <Button
-              variant="secondary"
-              size="sm"
-              onClick={() => setIsFullscreen((prev) => !prev)}
-              title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
-            >
-              {isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
-            </Button>
-            <Button variant="ghost" size="sm" onClick={onClose}>
-              <X className="w-5 h-5" />
+    <PreviewModalShell
+      title={title}
+      fullscreen={fullscreen}
+      onClose={onClose}
+      closeOnBackdropClick
+      titleExtra={
+        hasObjectCount ? (
+          <span className="text-xs text-bambu-gray bg-bambu-dark-tertiary/70 px-2 py-1 rounded whitespace-nowrap">
+            {objectCountLabel}: {t('modelViewer.objectCount', { count: selectedObjectCount })}
+          </span>
+        ) : undefined
+      }
+      actions={
+        <>
+          {useBambuddySlicer ? (
+            <SlicerSplitButton
+              icon={<Cog className="w-4 h-4" />}
+              label={t('slice.action')}
+              dropdownLabel={t('modelViewer.moreSlicerOptions')}
+              onPrimary={() => onSliceWithBambuddy?.()}
+              items={slicerDropdownItems}
+            />
+          ) : canOpenInSlicer ? (
+            <SlicerSplitButton
+              icon={<ExternalLink className="w-4 h-4" />}
+              // Name the slicer when it is not the configured one. That happens
+              // when the configured slicer cannot take this format — an STL with
+              // Bambu Studio selected — and silently handing the file to the
+              // other one without saying so would be worse than the failure it
+              // replaces.
+              label={
+                usableSlicers[0] === preferredSlicer
+                  ? t('modelViewer.openInSlicer')
+                  : t('modelViewer.openInSlicerWith', { slicer: slicerName(usableSlicers[0]) })
+              }
+              dropdownLabel={t('modelViewer.moreSlicerOptions')}
+              onPrimary={() => handleOpenInSlicer(usableSlicers[0])}
+              items={slicerDropdownItems}
+            />
+          ) : (
+            <Button variant="secondary" size="sm" disabled>
+              <ExternalLink className="w-4 h-4" />
+              {t('modelViewer.openInSlicer')}
             </Button>
-          </div>
+          )}
+        </>
+      }
+    >
+      {/* Tabs - only show if we have capabilities */}
+      {capabilities && (
+        <div className="flex border-b border-bambu-dark-tertiary">
+          <button
+            onClick={() => capabilities.has_model && setActiveTab('3d')}
+            disabled={!capabilities.has_model}
+            className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
+              activeTab === '3d'
+                ? 'text-bambu-green border-b-2 border-bambu-green'
+                : capabilities.has_model
+                  ? 'text-bambu-gray hover:text-white'
+                  : 'text-bambu-gray/30 cursor-not-allowed'
+            }`}
+          >
+            <Box className="w-4 h-4" />
+            {t('modelViewer.tabs.model')}
+            {!capabilities.has_model && <span className="text-xs">({t('modelViewer.notAvailable')})</span>}
+          </button>
         </div>
+      )}
 
-        {/* Tabs - only show if we have capabilities */}
-        {capabilities && (
-          <div className="flex border-b border-bambu-dark-tertiary">
-            <button
-              onClick={() => capabilities.has_model && setActiveTab('3d')}
-              disabled={!capabilities.has_model}
-              className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
-                activeTab === '3d'
-                  ? 'text-bambu-green border-b-2 border-bambu-green'
-                  : capabilities.has_model
-                    ? 'text-bambu-gray hover:text-white'
-                    : 'text-bambu-gray/30 cursor-not-allowed'
-              }`}
-            >
-              <Box className="w-4 h-4" />
-              {t('modelViewer.tabs.model')}
-              {!capabilities.has_model && <span className="text-xs">({t('modelViewer.notAvailable')})</span>}
-            </button>
+      {/* Viewer */}
+      <div className="flex-1 overflow-hidden p-4">
+        {loading ? (
+          <div className="w-full h-full flex items-center justify-center">
+            <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
           </div>
-        )}
-
-        {/* Viewer */}
-        <div className="flex-1 overflow-hidden p-4">
-          {loading ? (
-            <div className="w-full h-full flex items-center justify-center">
-              <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
-            </div>
-          ) : activeTab === '3d' && capabilities ? (
-            <div
-              ref={splitContainerRef}
-              className={`w-full h-full flex flex-col ${splitFullscreen ? 'gap-0 min-h-0' : 'gap-3'}`}
-            >
-              {hasMultiplePlates && (
-                <div
-                  ref={platesPanelRef}
-                  style={splitFullscreen && platePanelHeight != null ? { height: platePanelHeight } : undefined}
-                  className={`rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-3 ${splitFullscreen ? 'flex flex-col shrink-0' : ''}`}
-                >
-                  <div className="flex items-center gap-2 text-sm text-bambu-gray mb-2">
-                    <Layers className="w-4 h-4" />
-                    {t('modelViewer.plates')}
-                    {platesLoading && <Loader2 className="w-3 h-3 animate-spin" />}
-                  </div>
-                  <div className={splitFullscreen ? 'flex flex-col min-h-0 flex-1' : undefined}>
-                      <div
-                        ref={platesViewportRef}
-                        className={splitFullscreen ? 'min-h-0 overflow-hidden pr-1 flex-1' : undefined}
-                      >
-                      <div
-                        ref={platesGridRef}
-                        className={splitFullscreen ? 'grid gap-2' : 'grid grid-cols-2 md:grid-cols-3 gap-2'}
-                        style={splitFullscreen ? { gridTemplateColumns: `repeat(${plateColumns}, minmax(0, 1fr))` } : undefined}
+        ) : activeTab === '3d' && capabilities ? (
+          <div
+            ref={splitContainerRef}
+            className={`w-full h-full flex flex-col ${splitFullscreen ? 'gap-0 min-h-0' : 'gap-3'}`}
+          >
+            {hasMultiplePlates && (
+              <div
+                ref={platesPanelRef}
+                style={splitFullscreen && platePanelHeight != null ? { height: platePanelHeight } : undefined}
+                className={`rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-3 ${splitFullscreen ? 'flex flex-col shrink-0' : ''}`}
+              >
+                <div className="flex items-center gap-2 text-sm text-bambu-gray mb-2">
+                  <Layers className="w-4 h-4" />
+                  {t('modelViewer.plates')}
+                  {platesLoading && <Loader2 className="w-3 h-3 animate-spin" />}
+                </div>
+                <div className={splitFullscreen ? 'flex flex-col min-h-0 flex-1' : undefined}>
+                    <div
+                      ref={platesViewportRef}
+                      className={splitFullscreen ? 'min-h-0 overflow-hidden pr-1 flex-1' : undefined}
+                    >
+                    <div
+                      ref={platesGridRef}
+                      className={splitFullscreen ? 'grid gap-2' : 'grid grid-cols-2 md:grid-cols-3 gap-2'}
+                      style={splitFullscreen ? { gridTemplateColumns: `repeat(${plateColumns}, minmax(0, 1fr))` } : undefined}
+                    >
+                      <button
+                        type="button"
+                        onClick={() => setSelectedPlateId(null)}
+                        className={`flex items-center rounded-lg border text-left transition-colors ${
+                          splitFullscreen ? 'gap-1.5 p-1.5 w-full' : 'gap-2 p-2'
+                        } ${
+                          selectedPlateId == null
+                            ? 'border-bambu-green bg-bambu-green/10'
+                            : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
+                        }`}
                       >
+                        <div className={`rounded bg-bambu-dark-tertiary flex items-center justify-center ${
+                          splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'
+                        }`}>
+                          <Layers className={`${splitFullscreen ? 'w-4 h-4' : 'w-5 h-5'} text-bambu-gray`} />
+                        </div>
+                        <div className="min-w-0 flex-1">
+                          <p className={`${splitFullscreen ? 'text-xs' : 'text-sm'} text-white font-medium truncate`}>{t('modelViewer.allPlates')}</p>
+                          <p className={`${splitFullscreen ? 'text-[10px]' : 'text-xs'} text-bambu-gray truncate`}>
+                            {t('modelViewer.plateCount', { count: plates.length })}
+                          </p>
+                        </div>
+                        {selectedPlateId == null && (
+                          <Check className={`${splitFullscreen ? 'w-3.5 h-3.5' : 'w-4 h-4'} text-bambu-green flex-shrink-0`} />
+                        )}
+                      </button>
+                      {pagedPlates.map((plate) => (
                         <button
+                          key={plate.index}
                           type="button"
-                          onClick={() => setSelectedPlateId(null)}
+                          onClick={() => setSelectedPlateId(plate.index)}
                           className={`flex items-center rounded-lg border text-left transition-colors ${
                             splitFullscreen ? 'gap-1.5 p-1.5 w-full' : 'gap-2 p-2'
                           } ${
-                            selectedPlateId == null
+                            selectedPlateId === plate.index
                               ? 'border-bambu-green bg-bambu-green/10'
                               : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
                           }`}
                         >
-                          <div className={`rounded bg-bambu-dark-tertiary flex items-center justify-center ${
-                            splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'
-                          }`}>
-                            <Layers className={`${splitFullscreen ? 'w-4 h-4' : 'w-5 h-5'} text-bambu-gray`} />
-                          </div>
+                          {plate.has_thumbnail && plate.thumbnail_url ? (
+                            <img
+                              src={withMediaToken(plate.thumbnail_url)}
+                              alt={`Plate ${plate.index}`}
+                              className={`${splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'} rounded object-cover bg-bambu-dark-tertiary`}
+                            />
+                          ) : (
+                            <div className={`rounded bg-bambu-dark-tertiary flex items-center justify-center ${
+                              splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'
+                            }`}>
+                              <Layers className={`${splitFullscreen ? 'w-4 h-4' : 'w-5 h-5'} text-bambu-gray`} />
+                            </div>
+                          )}
                           <div className="min-w-0 flex-1">
-                            <p className={`${splitFullscreen ? 'text-xs' : 'text-sm'} text-white font-medium truncate`}>{t('modelViewer.allPlates')}</p>
+                            <p className={`${splitFullscreen ? 'text-xs' : 'text-sm'} text-white font-medium truncate`}>
+                              {plate.name || t('modelViewer.plateNumber', { number: plate.index })}
+                            </p>
                             <p className={`${splitFullscreen ? 'text-[10px]' : 'text-xs'} text-bambu-gray truncate`}>
-                              {t('modelViewer.plateCount', { count: plates.length })}
+                              {t('modelViewer.objectCount', { count: plate.object_count ?? plate.objects?.length ?? 0 })}
                             </p>
                           </div>
-                          {selectedPlateId == null && (
+                          {selectedPlateId === plate.index && (
                             <Check className={`${splitFullscreen ? 'w-3.5 h-3.5' : 'w-4 h-4'} text-bambu-green flex-shrink-0`} />
                           )}
                         </button>
-                        {pagedPlates.map((plate) => (
-                          <button
-                            key={plate.index}
-                            type="button"
-                            onClick={() => setSelectedPlateId(plate.index)}
-                            className={`flex items-center rounded-lg border text-left transition-colors ${
-                              splitFullscreen ? 'gap-1.5 p-1.5 w-full' : 'gap-2 p-2'
-                            } ${
-                              selectedPlateId === plate.index
-                                ? 'border-bambu-green bg-bambu-green/10'
-                                : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
-                            }`}
-                          >
-                            {plate.has_thumbnail && plate.thumbnail_url ? (
-                              <img
-                                src={withMediaToken(plate.thumbnail_url)}
-                                alt={`Plate ${plate.index}`}
-                                className={`${splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'} rounded object-cover bg-bambu-dark-tertiary`}
-                              />
-                            ) : (
-                              <div className={`rounded bg-bambu-dark-tertiary flex items-center justify-center ${
-                                splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'
-                              }`}>
-                                <Layers className={`${splitFullscreen ? 'w-4 h-4' : 'w-5 h-5'} text-bambu-gray`} />
-                              </div>
-                            )}
-                            <div className="min-w-0 flex-1">
-                              <p className={`${splitFullscreen ? 'text-xs' : 'text-sm'} text-white font-medium truncate`}>
-                                {plate.name || t('modelViewer.plateNumber', { number: plate.index })}
-                              </p>
-                              <p className={`${splitFullscreen ? 'text-[10px]' : 'text-xs'} text-bambu-gray truncate`}>
-                                {t('modelViewer.objectCount', { count: plate.object_count ?? plate.objects?.length ?? 0 })}
-                              </p>
-                            </div>
-                            {selectedPlateId === plate.index && (
-                              <Check className={`${splitFullscreen ? 'w-3.5 h-3.5' : 'w-4 h-4'} text-bambu-green flex-shrink-0`} />
-                            )}
-                          </button>
-                        ))}
-                      </div>
+                      ))}
                     </div>
-                    {(selectedPlate || shouldPaginatePlates) && (
-                      <div className="mt-auto pt-3 flex items-center gap-4 text-xs text-bambu-gray overflow-x-auto">
-                        {selectedPlate && (
-                          <div className="flex items-center gap-3 whitespace-nowrap">
-                            <span>{t('modelViewer.plateNumber', { number: selectedPlate.index })}</span>
-                            {selectedPlate.print_time_seconds != null && (
-                              <span>{t('modelViewer.eta', { minutes: Math.round(selectedPlate.print_time_seconds / 60) })}</span>
-                            )}
-                            {selectedPlate.filament_used_grams != null && (
-                              <span>{selectedPlate.filament_used_grams.toFixed(1)} g</span>
-                            )}
-                            {selectedPlate.filaments.length > 0 && (
-                              <span>{t('modelViewer.filamentCount', { count: selectedPlate.filaments.length })}</span>
-                            )}
-                          </div>
-                        )}
-                        {shouldPaginatePlates && (
-                          <div className={`flex items-center gap-2 whitespace-nowrap ${selectedPlate ? 'ml-auto' : ''}`}>
-                            <span>{t('modelViewer.pagination.pageOf', { current: platePage + 1, total: totalPlatePages })}</span>
-                            <div className="flex items-center gap-1">
-                              <button
-                                type="button"
-                                onClick={() => setPlatePage((prev) => Math.max(prev - 1, 0))}
-                                disabled={platePage === 0}
-                                className={`px-2 py-1 rounded border text-xs ${
-                                  platePage === 0
-                                    ? 'border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed'
-                                    : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
-                                }`}
-                              >
-                                {t('modelViewer.pagination.prev')}
-                              </button>
-                              {(() => {
-                                const maxVisible = 5;
-                                let start = Math.max(0, platePage - Math.floor(maxVisible / 2));
-                                const end = Math.min(totalPlatePages, start + maxVisible);
-                                if (end - start < maxVisible) {
-                                  start = Math.max(0, end - maxVisible);
-                                }
-                                const pages = Array.from({ length: end - start }, (_, i) => start + i);
-
-                                return (
-                                  <>
-                                    {start > 0 && (
-                                      <button
-                                        type="button"
-                                        onClick={() => setPlatePage(0)}
-                                        className={`px-2 py-1 rounded border text-xs ${
-                                          platePage === 0
-                                            ? 'border-bambu-green text-bambu-green'
-                                            : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
-                                        }`}
-                                      >
-                                        1
-                                      </button>
-                                    )}
-                                    {start > 1 && <span className="px-1">…</span>}
-                                    {pages.map((pageNumber) => (
-                                      <button
-                                        key={pageNumber}
-                                        type="button"
-                                        onClick={() => setPlatePage(pageNumber)}
-                                        className={`px-2 py-1 rounded border text-xs ${
-                                          platePage === pageNumber
-                                            ? 'border-bambu-green text-bambu-green'
-                                            : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
-                                        }`}
-                                      >
-                                        {pageNumber + 1}
-                                      </button>
-                                    ))}
-                                    {end < totalPlatePages - 1 && <span className="px-1">…</span>}
-                                    {end < totalPlatePages && (
-                                      <button
-                                        type="button"
-                                        onClick={() => setPlatePage(totalPlatePages - 1)}
-                                        className={`px-2 py-1 rounded border text-xs ${
-                                          platePage === totalPlatePages - 1
-                                            ? 'border-bambu-green text-bambu-green'
-                                            : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
-                                        }`}
-                                      >
-                                        {totalPlatePages}
-                                      </button>
-                                    )}
-                                  </>
-                                );
-                              })()}
-                              <button
-                                type="button"
-                                onClick={() => setPlatePage((prev) => Math.min(prev + 1, totalPlatePages - 1))}
-                                disabled={platePage >= totalPlatePages - 1}
-                                className={`px-2 py-1 rounded border text-xs ${
-                                  platePage >= totalPlatePages - 1
-                                    ? 'border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed'
-                                    : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
-                                }`}
-                              >
-                                {t('modelViewer.pagination.next')}
-                              </button>
-                            </div>
-                          </div>
-                        )}
-                      </div>
-                    )}
                   </div>
+                  {(selectedPlate || shouldPaginatePlates) && (
+                    <div className="mt-auto pt-3 flex items-center gap-4 text-xs text-bambu-gray overflow-x-auto">
+                      {selectedPlate && (
+                        <div className="flex items-center gap-3 whitespace-nowrap">
+                          <span>{t('modelViewer.plateNumber', { number: selectedPlate.index })}</span>
+                          {selectedPlate.print_time_seconds != null && (
+                            <span>{t('modelViewer.eta', { minutes: Math.round(selectedPlate.print_time_seconds / 60) })}</span>
+                          )}
+                          {selectedPlate.filament_used_grams != null && (
+                            <span>{selectedPlate.filament_used_grams.toFixed(1)} g</span>
+                          )}
+                          {selectedPlate.filaments.length > 0 && (
+                            <span>{t('modelViewer.filamentCount', { count: selectedPlate.filaments.length })}</span>
+                          )}
+                        </div>
+                      )}
+                      {shouldPaginatePlates && (
+                        <div className={`flex items-center gap-2 whitespace-nowrap ${selectedPlate ? 'ml-auto' : ''}`}>
+                          <span>{t('modelViewer.pagination.pageOf', { current: platePage + 1, total: totalPlatePages })}</span>
+                          <div className="flex items-center gap-1">
+                            <button
+                              type="button"
+                              onClick={() => setPlatePage((prev) => Math.max(prev - 1, 0))}
+                              disabled={platePage === 0}
+                              className={`px-2 py-1 rounded border text-xs ${
+                                platePage === 0
+                                  ? 'border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed'
+                                  : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
+                              }`}
+                            >
+                              {t('modelViewer.pagination.prev')}
+                            </button>
+                            {(() => {
+                              const maxVisible = 5;
+                              let start = Math.max(0, platePage - Math.floor(maxVisible / 2));
+                              const end = Math.min(totalPlatePages, start + maxVisible);
+                              if (end - start < maxVisible) {
+                                start = Math.max(0, end - maxVisible);
+                              }
+                              const pages = Array.from({ length: end - start }, (_, i) => start + i);
+
+                              return (
+                                <>
+                                  {start > 0 && (
+                                    <button
+                                      type="button"
+                                      onClick={() => setPlatePage(0)}
+                                      className={`px-2 py-1 rounded border text-xs ${
+                                        platePage === 0
+                                          ? 'border-bambu-green text-bambu-green'
+                                          : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
+                                      }`}
+                                    >
+                                      1
+                                    </button>
+                                  )}
+                                  {start > 1 && <span className="px-1">…</span>}
+                                  {pages.map((pageNumber) => (
+                                    <button
+                                      key={pageNumber}
+                                      type="button"
+                                      onClick={() => setPlatePage(pageNumber)}
+                                      className={`px-2 py-1 rounded border text-xs ${
+                                        platePage === pageNumber
+                                          ? 'border-bambu-green text-bambu-green'
+                                          : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
+                                      }`}
+                                    >
+                                      {pageNumber + 1}
+                                    </button>
+                                  ))}
+                                  {end < totalPlatePages - 1 && <span className="px-1">…</span>}
+                                  {end < totalPlatePages && (
+                                    <button
+                                      type="button"
+                                      onClick={() => setPlatePage(totalPlatePages - 1)}
+                                      className={`px-2 py-1 rounded border text-xs ${
+                                        platePage === totalPlatePages - 1
+                                          ? 'border-bambu-green text-bambu-green'
+                                          : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
+                                      }`}
+                                    >
+                                      {totalPlatePages}
+                                    </button>
+                                  )}
+                                </>
+                              );
+                            })()}
+                            <button
+                              type="button"
+                              onClick={() => setPlatePage((prev) => Math.min(prev + 1, totalPlatePages - 1))}
+                              disabled={platePage >= totalPlatePages - 1}
+                              className={`px-2 py-1 rounded border text-xs ${
+                                platePage >= totalPlatePages - 1
+                                  ? 'border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed'
+                                  : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
+                              }`}
+                            >
+                              {t('modelViewer.pagination.next')}
+                            </button>
+                          </div>
+                        </div>
+                      )}
+                    </div>
+                  )}
                 </div>
-              )}
-              {splitFullscreen && (
-                <div
-                  role="separator"
-                  aria-orientation="horizontal"
-                  onMouseDown={(event) => {
-                    event.preventDefault();
-                    setIsDraggingDivider(true);
-                    setHasCustomSplit(true);
-                  }}
-                  className={`h-2 cursor-row-resize flex items-center justify-center ${
-                    isDraggingDivider ? 'bg-bambu-dark-tertiary' : 'bg-bambu-dark-secondary/60 hover:bg-bambu-dark-tertiary'
-                  }`}
-                >
-                  <div className="w-12 h-1 rounded-full bg-bambu-gray/50" />
-                </div>
-              )}
-              <div className={`flex-1 ${splitFullscreen ? 'min-h-0' : ''}`}>
-                  <ModelViewer
-                    url={isLibrary
-                      ? api.getLibraryFileDownloadUrl(libraryFileId!)
-                      : (capabilities.has_source
-                        ? api.getSource3mfDownloadUrl(archiveId!)
-                        : api.getArchiveDownload(archiveId!))}
-                    fileType={fileType}
-                    buildVolume={capabilities.build_volume}
-                    filamentColors={capabilities.filament_colors}
-                    selectedPlateId={selectedPlateId}
-                    className="w-full h-full"
-                    onSnapshot={onSnapshot}
-                  />
               </div>
+            )}
+            {splitFullscreen && (
+              <div
+                role="separator"
+                aria-orientation="horizontal"
+                onMouseDown={(event) => {
+                  event.preventDefault();
+                  setIsDraggingDivider(true);
+                  setHasCustomSplit(true);
+                }}
+                className={`h-2 cursor-row-resize flex items-center justify-center ${
+                  isDraggingDivider ? 'bg-bambu-dark-tertiary' : 'bg-bambu-dark-secondary/60 hover:bg-bambu-dark-tertiary'
+                }`}
+              >
+                <div className="w-12 h-1 rounded-full bg-bambu-gray/50" />
+              </div>
+            )}
+            <div
+              className={`flex-1 ${splitFullscreen ? 'min-h-0' : ''}`}
+              data-testid="model-viewer-area"
+              onDoubleClick={toggleFullscreen}
+            >
+                <ModelViewer
+                  url={isLibrary
+                    ? api.getLibraryFileDownloadUrl(libraryFileId!)
+                    : (capabilities.has_source
+                      ? api.getSource3mfDownloadUrl(archiveId!)
+                      : api.getArchiveDownload(archiveId!))}
+                  fileType={fileType}
+                  buildVolume={capabilities.build_volume}
+                  filamentColors={capabilities.filament_colors}
+                  selectedPlateId={selectedPlateId}
+                  className="w-full h-full"
+                  onSnapshot={onSnapshot}
+                />
             </div>
-          ) : (
-            <div className="w-full h-full flex items-center justify-center text-bambu-gray">
-              {t('modelViewer.noPreview')}
-            </div>
-          )}
-        </div>
+          </div>
+        ) : (
+          <div className="w-full h-full flex items-center justify-center text-bambu-gray">
+            {t('modelViewer.noPreview')}
+          </div>
+        )}
       </div>
-    </div>
+    </PreviewModalShell>
   );
 }

+ 303 - 86
frontend/src/components/PdfPreviewModal.tsx

@@ -1,14 +1,40 @@
-import { useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { ChevronLeft, ChevronRight, FileText, Loader2, X, ZoomIn, ZoomOut } from 'lucide-react';
+import { ChevronLeft, ChevronRight, FileText, Loader2, ZoomIn, ZoomOut } from 'lucide-react';
 import type { PDFDocumentLoadingTask, PDFDocumentProxy, RenderTask } from 'pdfjs-dist/legacy/build/pdf.mjs';
 import { api, getAuthToken } from '../api/client';
 import { formatFileSize } from '../utils/file';
+import { PreviewModalShell, previewIconButtonClass } from './PreviewModalShell';
+import { usePreviewFullscreen } from '../hooks/usePreviewFullscreen';
 
 // Fetching and parsing happen fully in the browser; beyond this size the
 // preview shows a notice instead of stalling the tab on a giant download.
 export const PDF_PREVIEW_MAX_BYTES = 50 * 1024 * 1024;
 
+const MIN_ZOOM = 0.4;
+const MAX_ZOOM = 4;
+// One button press, one key press, one mouse-wheel notch.
+const ZOOM_STEP = 1.25;
+// A pinch arrives as many small ctrlKey wheel events, a mouse notch as one
+// ±100; clamping the delta before the exponent gives the notch one ZOOM_STEP
+// and the pinch a smooth ramp.
+const WHEEL_DELTA_CLAMP = 30;
+const WHEEL_ZOOM_RATE = Math.log(ZOOM_STEP) / WHEEL_DELTA_CLAMP;
+// Wheel zoom changes the displayed size at once (CSS) and re-rasterises after
+// the gesture settles, so a scroll burst costs one pdf.js render, not twenty.
+const RERENDER_DEBOUNCE_MS = 150;
+// iOS Safari refuses to back a canvas past roughly 16.7M pixels and hands back
+// a blank one instead of failing; zoom 4 on a dpr-2 screen crosses that on any
+// ordinary page. Past the cap the raster stops getting sharper, which costs
+// detail rather than the whole page.
+const MAX_CANVAS_PIXELS = 16 * 1024 * 1024;
+// pdf.js fetches its CMaps, ICC profiles, standard fonts and wasm decoders at
+// runtime instead of bundling them; vite.config.ts publishes them here. Left
+// unset, CJK text, JPEG2000/JBIG2 images and ICC colour silently fail (#2976).
+const PDFJS_ASSET_BASE = `${import.meta.env.BASE_URL}assets/pdfjs/`;
+
+const clampZoom = (zoom: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom));
+
 interface PdfPreviewModalProps {
   libraryFileId: number;
   filename: string;
@@ -18,6 +44,20 @@ interface PdfPreviewModalProps {
   onSnapshot?: (blob: Blob) => void;
 }
 
+// Where a zoom step should keep the page still: a point on the canvas
+// (fractions) pinned to a point in the scroll viewport (pixels).
+interface ZoomAnchor {
+  fx: number;
+  fy: number;
+  px: number;
+  py: number;
+}
+
+interface ActivePointer {
+  x: number;
+  y: number;
+}
+
 // Square 256px crop of the rendered page, white-backed like a paper page.
 function snapshotFromCanvas(source: HTMLCanvasElement): Promise<Blob | null> {
   const size = 256;
@@ -35,8 +75,17 @@ function snapshotFromCanvas(source: HTMLCanvasElement): Promise<Blob | null> {
   return new Promise((resolve) => target.toBlob(resolve, 'image/png'));
 }
 
+// deltaMode 1 is lines (Firefox mouse wheel), 2 is pages; both scaled to pixels.
+function wheelDeltaPixels(e: WheelEvent): number {
+  if (e.deltaMode === 1) return e.deltaY * 16;
+  if (e.deltaMode === 2) return e.deltaY * 100;
+  return e.deltaY;
+}
+
 export function PdfPreviewModal({ libraryFileId, filename, fileSize, onClose, onSnapshot }: PdfPreviewModalProps) {
   const { t } = useTranslation();
+  const fullscreen = usePreviewFullscreen();
+  const { panelRef, isFullscreen, toggleFullscreen } = fullscreen;
   const containerRef = useRef<HTMLDivElement>(null);
   const canvasRef = useRef<HTMLCanvasElement>(null);
   const renderTaskRef = useRef<RenderTask | null>(null);
@@ -48,17 +97,160 @@ export function PdfPreviewModal({ libraryFileId, filename, fileSize, onClose, on
 
   const [doc, setDoc] = useState<PDFDocumentProxy | null>(null);
   const [pageNum, setPageNum] = useState(1);
+  // `zoom` is what the user sees (applied as CSS size immediately);
+  // `renderZoom` trails it and drives the pdf.js raster.
   const [zoom, setZoom] = useState(1);
+  const [renderZoom, setRenderZoom] = useState(1);
+  const [layoutVersion, setLayoutVersion] = useState(0);
   const [error, setError] = useState<string | null>(null);
   const [rendering, setRendering] = useState(true);
 
+  const zoomRef = useRef(zoom);
+  // CSS size of the page at zoom 1, known once a page has been rendered.
+  const baseCssSizeRef = useRef<{ width: number; height: number } | null>(null);
+  const anchorRef = useRef<ZoomAnchor | null>(null);
+  const pointersRef = useRef<Map<number, ActivePointer>>(new Map());
+  const pinchDistanceRef = useRef<number | null>(null);
+
+  const zoomAt = useCallback((factor: number, clientX?: number, clientY?: number) => {
+    const container = containerRef.current;
+    const canvas = canvasRef.current;
+    if (container && canvas) {
+      const viewport = container.getBoundingClientRect();
+      const page = canvas.getBoundingClientRect();
+      // Without a pointer, keep whatever is in the middle of the viewport.
+      const px = clientX == null ? viewport.width / 2 : clientX - viewport.left;
+      const py = clientY == null ? viewport.height / 2 : clientY - viewport.top;
+      anchorRef.current =
+        page.width > 0 && page.height > 0
+          ? {
+              fx: (px + viewport.left - page.left) / page.width,
+              fy: (py + viewport.top - page.top) / page.height,
+              px,
+              py,
+            }
+          : null;
+    }
+    setZoom((current) => clampZoom(current * factor));
+  }, []);
+
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
-      if (e.key === 'Escape') onClose();
+      // Ctrl/⌘ combinations are the browser's own zoom; leave them alone.
+      if (e.ctrlKey || e.metaKey || e.altKey) return;
+      if (e.key === '+' || e.key === '=') {
+        e.preventDefault();
+        zoomAt(ZOOM_STEP);
+      } else if (e.key === '-') {
+        e.preventDefault();
+        zoomAt(1 / ZOOM_STEP);
+      } else if (e.key === '0') {
+        e.preventDefault();
+        anchorRef.current = null;
+        setZoom(1);
+      }
     };
     window.addEventListener('keydown', handleKeyDown);
     return () => window.removeEventListener('keydown', handleKeyDown);
-  }, [onClose]);
+  }, [zoomAt]);
+
+  // Wheel: Ctrl/⌘ (which is also how a trackpad pinch arrives) always zooms;
+  // a plain wheel zooms only while the page fits the viewport, because then
+  // there is nothing to scroll. Native listener: React registers wheel as
+  // passive, so preventDefault — needed to keep the browser from zooming the
+  // whole page — would be ignored there.
+  useEffect(() => {
+    const container = containerRef.current;
+    if (!container) return;
+    const handleWheel = (e: WheelEvent) => {
+      if (!canvasRef.current || !baseCssSizeRef.current) return;
+      const modified = e.ctrlKey || e.metaKey;
+      if (!modified) {
+        const fits =
+          container.scrollHeight <= container.clientHeight && container.scrollWidth <= container.clientWidth;
+        if (!fits) return;
+      }
+      e.preventDefault();
+      const delta = Math.max(-WHEEL_DELTA_CLAMP, Math.min(WHEEL_DELTA_CLAMP, wheelDeltaPixels(e)));
+      zoomAt(Math.exp(-delta * WHEEL_ZOOM_RATE), e.clientX, e.clientY);
+    };
+    container.addEventListener('wheel', handleWheel, { passive: false });
+    return () => container.removeEventListener('wheel', handleWheel);
+  }, [zoomAt]);
+
+  // Touch: one finger pans the page, two fingers pinch-zoom around their
+  // midpoint. `touch-action: none` on the container hands both gestures to
+  // these handlers instead of the browser's scroll / page-zoom.
+  const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
+    if (e.pointerType !== 'touch') return;
+    pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
+    pinchDistanceRef.current = null;
+  };
+
+  const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
+    if (e.pointerType !== 'touch') return;
+    const pointers = pointersRef.current;
+    const previous = pointers.get(e.pointerId);
+    if (!previous) return;
+    pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
+
+    if (pointers.size === 1) {
+      const container = containerRef.current;
+      if (container) {
+        container.scrollLeft -= e.clientX - previous.x;
+        container.scrollTop -= e.clientY - previous.y;
+      }
+      return;
+    }
+    if (pointers.size !== 2) return;
+    const [a, b] = Array.from(pointers.values());
+    const distance = Math.hypot(a.x - b.x, a.y - b.y);
+    const last = pinchDistanceRef.current;
+    pinchDistanceRef.current = distance;
+    if (last != null && last > 0 && distance > 0) {
+      zoomAt(distance / last, (a.x + b.x) / 2, (a.y + b.y) / 2);
+    }
+  };
+
+  const handlePointerEnd = (e: React.PointerEvent<HTMLDivElement>) => {
+    if (e.pointerType !== 'touch') return;
+    pointersRef.current.delete(e.pointerId);
+    pinchDistanceRef.current = null;
+  };
+
+  // Show the new zoom right away by resizing the existing raster, and keep
+  // the anchored point under the pointer. The sharp re-render follows.
+  useLayoutEffect(() => {
+    zoomRef.current = zoom;
+    const canvas = canvasRef.current;
+    const container = containerRef.current;
+    const base = baseCssSizeRef.current;
+    if (!canvas || !container || !base) return;
+    canvas.style.width = `${base.width * zoom}px`;
+    canvas.style.height = `${base.height * zoom}px`;
+    const anchor = anchorRef.current;
+    anchorRef.current = null;
+    if (!anchor) return;
+    container.scrollLeft = canvas.offsetLeft + anchor.fx * canvas.offsetWidth - anchor.px;
+    container.scrollTop = canvas.offsetTop + anchor.fy * canvas.offsetHeight - anchor.py;
+  }, [zoom]);
+
+  useEffect(() => {
+    if (zoom === renderZoom) return;
+    const id = window.setTimeout(() => setRenderZoom(zoom), RERENDER_DEBOUNCE_MS);
+    return () => window.clearTimeout(id);
+  }, [zoom, renderZoom]);
+
+  // The page is fitted to the panel width, so a panel resize (fullscreen,
+  // window resize) needs a fresh fit. Watching the panel rather than the
+  // scroll container keeps a scrollbar appearing from re-fitting the page.
+  useEffect(() => {
+    const panel = panelRef.current;
+    if (!panel) return;
+    const observer = new ResizeObserver(() => setLayoutVersion((v) => v + 1));
+    observer.observe(panel);
+    return () => observer.disconnect();
+  }, [panelRef]);
 
   // Load the document. pdf.js is imported on demand so the viewer and its
   // worker stay out of the main bundle.
@@ -98,14 +290,32 @@ export function PdfPreviewModal({ libraryFileId, filename, fileSize, onClose, on
           await import('pdfjs-dist/legacy/build/pdf.worker.min.mjs?worker&url')
         ).default;
       }
-      loadingTask = pdfjs.getDocument({ data: new Uint8Array(buffer) });
+      loadingTask = pdfjs.getDocument({
+        data: new Uint8Array(buffer),
+        cMapUrl: `${PDFJS_ASSET_BASE}cmaps/`,
+        iccUrl: `${PDFJS_ASSET_BASE}iccs/`,
+        standardFontDataUrl: `${PDFJS_ASSET_BASE}standard_fonts/`,
+        wasmUrl: `${PDFJS_ASSET_BASE}wasm/`,
+      });
+      if (cancelled) {
+        // The modal closed during the fetch/import above, so cleanup ran while
+        // `loadingTask` was still null and left this task — and its worker —
+        // running. Nothing else will destroy it.
+        loadingTask.destroy();
+        return;
+      }
       const loaded = await loadingTask.promise;
       if (cancelled) {
-        // Cleanup below already ran; destroying the task tears down the doc.
+        // Cleanup ran after the assignment above, so it destroyed the task
+        // already — and with it the document.
         return;
       }
       setDoc(loaded);
-    })().catch(() => {
+    })().catch((err: unknown) => {
+      // The reason never reaches the UI beyond a generic line, so leave it in
+      // the console: an HTTP status, a refused worker or a parser failure each
+      // need a different fix, and "cannot be previewed" hides which one it was.
+      console.error('[pdf-preview] load failed', err);
       if (!cancelled) {
         setError(t('fileManager.preview.error'));
         setRendering(false);
@@ -137,11 +347,16 @@ export function PdfPreviewModal({ libraryFileId, filename, fileSize, onClose, on
       // Fit the page width to the panel at zoom 1; render at device pixels.
       const fitScale = Math.max((container.clientWidth - 32) / baseViewport.width, 0.1);
       const dpr = Math.min(window.devicePixelRatio || 1, 2);
-      const viewport = page.getViewport({ scale: fitScale * zoom * dpr });
+      const wanted = fitScale * renderZoom * dpr;
+      const pixels = baseViewport.width * wanted * (baseViewport.height * wanted);
+      const scale = pixels > MAX_CANVAS_PIXELS ? wanted * Math.sqrt(MAX_CANVAS_PIXELS / pixels) : wanted;
+      const viewport = page.getViewport({ scale });
       canvas.width = viewport.width;
       canvas.height = viewport.height;
-      canvas.style.width = `${viewport.width / dpr}px`;
-      canvas.style.height = `${viewport.height / dpr}px`;
+      baseCssSizeRef.current = { width: baseViewport.width * fitScale, height: baseViewport.height * fitScale };
+      // The displayed zoom may already be ahead of this raster.
+      canvas.style.width = `${baseCssSizeRef.current.width * zoomRef.current}px`;
+      canvas.style.height = `${baseCssSizeRef.current.height * zoomRef.current}px`;
 
       renderTaskRef.current?.cancel();
       const task = page.render({ canvas, viewport });
@@ -158,6 +373,7 @@ export function PdfPreviewModal({ libraryFileId, filename, fileSize, onClose, on
     })().catch((err: unknown) => {
       // A cancelled render throws RenderingCancelledException — not an error.
       if (!cancelled && (err as { name?: string })?.name !== 'RenderingCancelledException') {
+        console.error('[pdf-preview] render failed', err);
         setError(t('fileManager.preview.error'));
         setRendering(false);
       }
@@ -166,89 +382,90 @@ export function PdfPreviewModal({ libraryFileId, filename, fileSize, onClose, on
     return () => {
       cancelled = true;
     };
-  }, [doc, pageNum, zoom, t]);
+  }, [doc, pageNum, renderZoom, layoutVersion, t]);
 
   const pageCount = doc?.numPages ?? 0;
+  const iconButtonClass = previewIconButtonClass;
 
   return (
-    <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
-      <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-5xl h-[85vh] border border-bambu-dark-tertiary flex flex-col">
-        {/* Header */}
-        <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
-          <div className="flex items-center gap-2 min-w-0">
-            <FileText className="w-5 h-5 text-bambu-green flex-shrink-0" />
-            <h2 className="text-lg font-semibold text-white truncate">{filename}</h2>
-          </div>
-          <div className="flex items-center gap-2 flex-shrink-0">
-            {doc && pageCount > 1 && (
-              <div className="flex items-center gap-1 mr-2">
-                <button
-                  onClick={() => setPageNum((p) => Math.max(1, p - 1))}
-                  disabled={pageNum <= 1}
-                  className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
-                  aria-label={t('fileManager.preview.prevPage')}
-                >
-                  <ChevronLeft className="w-4 h-4" />
-                </button>
-                <span className="text-sm text-bambu-gray whitespace-nowrap">
-                  {t('fileManager.preview.page', { current: pageNum, total: pageCount })}
-                </span>
-                <button
-                  onClick={() => setPageNum((p) => Math.min(pageCount, p + 1))}
-                  disabled={pageNum >= pageCount}
-                  className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
-                  aria-label={t('fileManager.preview.nextPage')}
-                >
-                  <ChevronRight className="w-4 h-4" />
-                </button>
-              </div>
-            )}
-            {doc && (
-              <>
-                <button
-                  onClick={() => setZoom((z) => Math.max(0.4, z * 0.8))}
-                  className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
-                  aria-label={t('fileManager.preview.zoomOut')}
-                >
-                  <ZoomOut className="w-4 h-4" />
-                </button>
-                <button
-                  onClick={() => setZoom((z) => Math.min(4, z * 1.25))}
-                  className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
-                  aria-label={t('fileManager.preview.zoomIn')}
-                >
-                  <ZoomIn className="w-4 h-4" />
-                </button>
-              </>
-            )}
-            <button
-              onClick={onClose}
-              className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
-              aria-label={t('common.close')}
-            >
-              <X className="w-5 h-5" />
-            </button>
-          </div>
-        </div>
-
-        {/* Page */}
-        <div ref={containerRef} className="relative flex-1 min-h-0 overflow-auto bg-bambu-dark rounded-b-lg p-4">
-          {error ? (
-            <div className="h-full flex items-center justify-center">
-              <p className="text-bambu-gray text-center">{error}</p>
-            </div>
-          ) : (
-            <div className="flex justify-center min-w-fit">
-              <canvas ref={canvasRef} className="shadow-lg" />
+    <PreviewModalShell
+      title={filename}
+      fullscreen={fullscreen}
+      onClose={onClose}
+      icon={<FileText className="w-5 h-5 text-bambu-green flex-shrink-0" />}
+      actions={
+        <>
+          {doc && pageCount > 1 && (
+            <div className="flex items-center gap-1 mr-2">
+              <button
+                onClick={() => setPageNum((p) => Math.max(1, p - 1))}
+                disabled={pageNum <= 1}
+                className={`${iconButtonClass} disabled:opacity-40 disabled:cursor-not-allowed`}
+                aria-label={t('fileManager.preview.prevPage')}
+              >
+                <ChevronLeft className="w-4 h-4" />
+              </button>
+              <span className="text-sm text-bambu-gray whitespace-nowrap">
+                {t('fileManager.preview.page', { current: pageNum, total: pageCount })}
+              </span>
+              <button
+                onClick={() => setPageNum((p) => Math.min(pageCount, p + 1))}
+                disabled={pageNum >= pageCount}
+                className={`${iconButtonClass} disabled:opacity-40 disabled:cursor-not-allowed`}
+                aria-label={t('fileManager.preview.nextPage')}
+              >
+                <ChevronRight className="w-4 h-4" />
+              </button>
             </div>
           )}
-          {!error && rendering && (
-            <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
-              <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
-            </div>
+          {doc && (
+            <>
+              <button
+                onClick={() => zoomAt(1 / ZOOM_STEP)}
+                className={iconButtonClass}
+                aria-label={t('fileManager.preview.zoomOut')}
+              >
+                <ZoomOut className="w-4 h-4" />
+              </button>
+              <button
+                onClick={() => zoomAt(ZOOM_STEP)}
+                className={iconButtonClass}
+                aria-label={t('fileManager.preview.zoomIn')}
+              >
+                <ZoomIn className="w-4 h-4" />
+              </button>
+            </>
           )}
-        </div>
+        </>
+      }
+    >
+      {/* Page */}
+      <div
+        ref={containerRef}
+        data-testid="pdf-preview-page"
+        className={`relative flex-1 min-h-0 overflow-auto bg-bambu-dark p-4 touch-none ${isFullscreen ? '' : 'rounded-b-lg'}`}
+        onDoubleClick={toggleFullscreen}
+        onPointerDown={handlePointerDown}
+        onPointerMove={handlePointerMove}
+        onPointerUp={handlePointerEnd}
+        onPointerCancel={handlePointerEnd}
+        onPointerLeave={handlePointerEnd}
+      >
+        {error ? (
+          <div className="h-full flex items-center justify-center">
+            <p className="text-bambu-gray text-center">{error}</p>
+          </div>
+        ) : (
+          <div className="flex justify-center min-w-fit">
+            <canvas ref={canvasRef} className="shadow-lg" />
+          </div>
+        )}
+        {!error && rendering && (
+          <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
+            <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+          </div>
+        )}
       </div>
-    </div>
+    </PreviewModalShell>
   );
 }

+ 101 - 0
frontend/src/components/PreviewModalShell.tsx

@@ -0,0 +1,101 @@
+import { useEffect, type ReactNode } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Maximize2, Minimize2, X } from 'lucide-react';
+import type { PreviewFullscreen } from '../hooks/usePreviewFullscreen';
+
+// One size for every file preview (#2976). Each modal used to carry its own
+// max-width (4xl/5xl/6xl at 80-85vh), which left most of a wide screen empty
+// for a window the user opened precisely to look at something closely.
+// min() keeps the panel off the edges on a laptop and stops it stretching to
+// a wall of pixels on an ultrawide.
+export const PREVIEW_PANEL_SIZE_CLASS = 'w-[min(1800px,96vw)] h-[94vh]';
+
+// Header icon buttons of every preview.
+export const previewIconButtonClass =
+  'p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors';
+
+interface PreviewModalShellProps {
+  title: string;
+  fullscreen: PreviewFullscreen;
+  onClose: () => void;
+  /** Type icon left of the title. */
+  icon?: ReactNode;
+  /** Rendered after the title, e.g. the 3D viewer's object-count badge. */
+  titleExtra?: ReactNode;
+  /** This preview's own header buttons, left of the fullscreen/close pair. */
+  actions?: ReactNode;
+  /** The 3D viewer closes on a backdrop click; the document previews do not. */
+  closeOnBackdropClick?: boolean;
+  /** Panel content: tabs, body and footers, as direct flex children. */
+  children: ReactNode;
+}
+
+/**
+ * Backdrop, panel sizing and header row shared by every file preview (#2976).
+ *
+ * Esc closes the modal, except while the browser owns it: in fullscreen Esc
+ * leaves fullscreen and the preview stays open. Double-click-to-fullscreen is
+ * wired by each modal on its own content area, because only the modal knows
+ * which part of the panel is the preview.
+ */
+export function PreviewModalShell({
+  title,
+  fullscreen,
+  onClose,
+  icon,
+  titleExtra,
+  actions,
+  closeOnBackdropClick,
+  children,
+}: PreviewModalShellProps) {
+  const { t } = useTranslation();
+  const { panelRef, isFullscreen, toggleFullscreen } = fullscreen;
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && !document.fullscreenElement) onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  return (
+    <div
+      className={`fixed inset-0 bg-black/70 flex items-center justify-center z-50 ${isFullscreen ? 'p-0' : 'p-2 sm:p-4'}`}
+      onClick={closeOnBackdropClick ? onClose : undefined}
+    >
+      <div
+        ref={panelRef}
+        className={`bg-bambu-dark-secondary border border-bambu-dark-tertiary flex flex-col ${
+          isFullscreen
+            ? 'w-full h-full max-w-none rounded-none'
+            : `${PREVIEW_PANEL_SIZE_CLASS} max-w-full max-h-full rounded-lg`
+        }`}
+        onClick={closeOnBackdropClick ? (e) => e.stopPropagation() : undefined}
+      >
+        <div className="flex items-center justify-between gap-4 p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
+          <div className="flex items-center gap-2 min-w-0">
+            {icon}
+            <h2 className="text-lg font-semibold text-white truncate">{title}</h2>
+            {titleExtra}
+          </div>
+          <div className="flex items-center gap-2 flex-shrink-0">
+            {actions}
+            <button
+              onClick={toggleFullscreen}
+              className={previewIconButtonClass}
+              aria-label={isFullscreen ? t('fileManager.preview.exitFullscreen') : t('fileManager.preview.fullscreen')}
+              title={isFullscreen ? t('fileManager.preview.exitFullscreen') : t('fileManager.preview.fullscreen')}
+            >
+              {isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
+            </button>
+            <button onClick={onClose} className={previewIconButtonClass} aria-label={t('common.close')}>
+              <X className="w-5 h-5" />
+            </button>
+          </div>
+        </div>
+        {children}
+      </div>
+    </div>
+  );
+}

+ 118 - 109
frontend/src/components/SpreadsheetPreviewModal.tsx

@@ -1,8 +1,10 @@
 import { useEffect, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { FileSpreadsheet, Loader2, X } from 'lucide-react';
+import { FileSpreadsheet, Loader2 } from 'lucide-react';
 import { api, getAuthToken } from '../api/client';
 import { formatFileSize } from '../utils/file';
+import { PreviewModalShell } from './PreviewModalShell';
+import { usePreviewFullscreen } from '../hooks/usePreviewFullscreen';
 
 // Parsing an arbitrarily large workbook would freeze the tab — anything over
 // this size (or beyond the row/column caps) falls back to a truncation notice.
@@ -101,14 +103,8 @@ export function SpreadsheetPreviewModal({
   useEffect(() => {
     onSnapshotRef.current = onSnapshot;
   });
-
-  useEffect(() => {
-    const handleKeyDown = (e: KeyboardEvent) => {
-      if (e.key === 'Escape') onClose();
-    };
-    window.addEventListener('keydown', handleKeyDown);
-    return () => window.removeEventListener('keydown', handleKeyDown);
-  }, [onClose]);
+  const fullscreen = usePreviewFullscreen();
+  const { isFullscreen, toggleFullscreen } = fullscreen;
 
   useEffect(() => {
     let cancelled = false;
@@ -135,15 +131,33 @@ export function SpreadsheetPreviewModal({
         // papaparse is loaded on demand so it stays out of the main bundle.
         const Papa = (await import('papaparse')).default;
         const text = new TextDecoder().decode(buffer);
-        const result = Papa.parse<string[]>(text, { skipEmptyLines: false });
-        const all = result.data.filter((row) => Array.isArray(row));
-        // A trailing newline parses as one empty row — drop trailing blanks.
-        while (all.length > 0 && all[all.length - 1].every((cell) => !cell)) {
-          all.pop();
-        }
-        const totalRows = all.length;
-        const totalCols = all.reduce((max, row) => Math.max(max, row.length), 0);
-        const rows = all.slice(0, MAX_ROWS).map((row) => row.slice(0, MAX_COLS).map((cell) => cell ?? ''));
+        const rows: string[][] = [];
+        let totalRows = 0;
+        let totalCols = 0;
+        // A trailing newline parses as one empty row, and only a *trailing*
+        // run of blanks is dropped — so blanks are held back until a later
+        // row proves they were interior.
+        let pendingBlanks: string[][] = [];
+        // Counted and capped row by row, so the whole file is never
+        // materialised as rows the preview then throws away.
+        const take = (row: string[]) => {
+          totalRows += 1;
+          totalCols = Math.max(totalCols, row.length);
+          if (rows.length < MAX_ROWS) rows.push(row.slice(0, MAX_COLS).map((cell) => cell ?? ''));
+        };
+        Papa.parse<string[]>(text, {
+          skipEmptyLines: false,
+          step: ({ data }) => {
+            if (!Array.isArray(data)) return;
+            if (data.every((cell) => !cell)) {
+              pendingBlanks.push(data);
+              return;
+            }
+            pendingBlanks.forEach(take);
+            pendingBlanks = [];
+            take(data);
+          },
+        });
         parsed = [{ name: filename, rows, totalRows, totalCols }];
       } else {
         // SheetJS handles both XLSX and ODS; loaded on demand like papaparse.
@@ -186,7 +200,8 @@ export function SpreadsheetPreviewModal({
           if (blob && !cancelled) onSnapshotRef.current?.(blob);
         }
       }
-    })().catch(() => {
+    })().catch((err: unknown) => {
+      console.error('[sheet-preview] load failed', err);
       if (!cancelled) setError(t('fileManager.preview.error'));
     });
 
@@ -201,105 +216,99 @@ export function SpreadsheetPreviewModal({
   const shownCols = sheet == null ? 0 : Math.min(sheet.totalCols, MAX_COLS);
 
   return (
-    <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
-      <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-6xl h-[85vh] border border-bambu-dark-tertiary flex flex-col">
-        {/* Header */}
-        <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
-          <div className="flex items-center gap-2 min-w-0">
-            <FileSpreadsheet className="w-5 h-5 text-bambu-green flex-shrink-0" />
-            <h2 className="text-lg font-semibold text-white truncate">{filename}</h2>
-          </div>
-          <button
-            onClick={onClose}
-            className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
-            aria-label={t('common.close')}
-          >
-            <X className="w-5 h-5" />
-          </button>
+    <PreviewModalShell
+      title={filename}
+      fullscreen={fullscreen}
+      onClose={onClose}
+      icon={<FileSpreadsheet className="w-5 h-5 text-bambu-green flex-shrink-0" />}
+    >
+      {/* Sheet tabs */}
+      {sheets && sheets.length > 1 && (
+        <div className="flex gap-1 px-4 pt-2 overflow-x-auto flex-shrink-0">
+          {sheets.map((s, index) => (
+            <button
+              key={`${s.name}-${index}`}
+              onClick={() => setActiveSheet(index)}
+              className={`px-3 py-1.5 text-sm rounded-t whitespace-nowrap transition-colors ${
+                index === activeSheet
+                  ? 'bg-bambu-dark text-white border border-b-0 border-bambu-dark-tertiary'
+                  : 'text-bambu-gray hover:text-white hover:bg-bambu-dark/50'
+              }`}
+            >
+              {s.name}
+            </button>
+          ))}
         </div>
+      )}
 
-        {/* Sheet tabs */}
-        {sheets && sheets.length > 1 && (
-          <div className="flex gap-1 px-4 pt-2 overflow-x-auto flex-shrink-0">
-            {sheets.map((s, index) => (
-              <button
-                key={`${s.name}-${index}`}
-                onClick={() => setActiveSheet(index)}
-                className={`px-3 py-1.5 text-sm rounded-t whitespace-nowrap transition-colors ${
-                  index === activeSheet
-                    ? 'bg-bambu-dark text-white border border-b-0 border-bambu-dark-tertiary'
-                    : 'text-bambu-gray hover:text-white hover:bg-bambu-dark/50'
-                }`}
-              >
-                {s.name}
-              </button>
-            ))}
+      {/* Content */}
+      <div
+        data-testid="spreadsheet-preview-content"
+        className={`flex-1 min-h-0 overflow-auto bg-bambu-dark ${isFullscreen ? '' : 'rounded-b-lg'}`}
+        onDoubleClick={toggleFullscreen}
+      >
+        {error ? (
+          <div className="h-full flex items-center justify-center p-6">
+            <p className="text-bambu-gray text-center">{error}</p>
           </div>
-        )}
-
-        {/* Content */}
-        <div className="flex-1 min-h-0 overflow-auto bg-bambu-dark rounded-b-lg">
-          {error ? (
-            <div className="h-full flex items-center justify-center p-6">
-              <p className="text-bambu-gray text-center">{error}</p>
-            </div>
-          ) : !sheets ? (
-            <div className="h-full flex items-center justify-center">
-              <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
-            </div>
-          ) : !sheet || sheet.rows.length === 0 ? (
-            <div className="h-full flex items-center justify-center p-6">
-              <p className="text-bambu-gray">{t('fileManager.preview.emptySheet')}</p>
-            </div>
-          ) : (
-            <table className="border-collapse text-xs">
-              <thead>
-                <tr>
-                  <th className="sticky top-0 bg-bambu-dark-secondary border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray font-medium w-10" />
+        ) : !sheets ? (
+          <div className="h-full flex items-center justify-center">
+            <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+          </div>
+        ) : !sheet || sheet.rows.length === 0 ? (
+          <div className="h-full flex items-center justify-center p-6">
+            <p className="text-bambu-gray">{t('fileManager.preview.emptySheet')}</p>
+          </div>
+        ) : (
+          // min-w-full so a narrow sheet spreads across the wider panel
+          // instead of huddling in its top-left corner (#2976).
+          <table className="border-collapse text-xs min-w-full">
+            <thead>
+              <tr>
+                <th className="sticky top-0 bg-bambu-dark-secondary border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray font-medium w-10" />
+                {Array.from({ length: shownCols }, (_, c) => (
+                  <th
+                    key={c}
+                    className="sticky top-0 bg-bambu-dark-secondary border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray font-medium text-left min-w-[80px]"
+                  >
+                    {columnLabel(c)}
+                  </th>
+                ))}
+              </tr>
+            </thead>
+            <tbody>
+              {sheet.rows.map((row, r) => (
+                <tr key={r}>
+                  <td className="border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray text-right bg-bambu-dark-secondary/50">
+                    {r + 1}
+                  </td>
                   {Array.from({ length: shownCols }, (_, c) => (
-                    <th
+                    <td
                       key={c}
-                      className="sticky top-0 bg-bambu-dark-secondary border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray font-medium text-left min-w-[80px]"
+                      className="border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray-light whitespace-nowrap max-w-[280px] overflow-hidden text-ellipsis"
+                      title={row[c] || undefined}
                     >
-                      {columnLabel(c)}
-                    </th>
+                      {row[c] ?? ''}
+                    </td>
                   ))}
                 </tr>
-              </thead>
-              <tbody>
-                {sheet.rows.map((row, r) => (
-                  <tr key={r}>
-                    <td className="border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray text-right bg-bambu-dark-secondary/50">
-                      {r + 1}
-                    </td>
-                    {Array.from({ length: shownCols }, (_, c) => (
-                      <td
-                        key={c}
-                        className="border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray-light whitespace-nowrap max-w-[280px] overflow-hidden text-ellipsis"
-                        title={row[c] || undefined}
-                      >
-                        {row[c] ?? ''}
-                      </td>
-                    ))}
-                  </tr>
-                ))}
-              </tbody>
-            </table>
-          )}
-        </div>
-
-        {/* Truncation notices */}
-        {(rowsTruncated || colsTruncated) && sheet && (
-          <div className="px-4 py-2 border-t border-bambu-dark-tertiary text-xs text-bambu-gray flex gap-4 flex-shrink-0">
-            {rowsTruncated && (
-              <span>{t('fileManager.preview.truncatedRows', { shown: sheet.rows.length, total: sheet.totalRows })}</span>
-            )}
-            {colsTruncated && (
-              <span>{t('fileManager.preview.truncatedCols', { shown: shownCols, total: sheet.totalCols })}</span>
-            )}
-          </div>
+              ))}
+            </tbody>
+          </table>
         )}
       </div>
-    </div>
+
+      {/* Truncation notices */}
+      {(rowsTruncated || colsTruncated) && sheet && (
+        <div className="px-4 py-2 border-t border-bambu-dark-tertiary text-xs text-bambu-gray flex gap-4 flex-shrink-0">
+          {rowsTruncated && (
+            <span>{t('fileManager.preview.truncatedRows', { shown: sheet.rows.length, total: sheet.totalRows })}</span>
+          )}
+          {colsTruncated && (
+            <span>{t('fileManager.preview.truncatedCols', { shown: shownCols, total: sheet.totalCols })}</span>
+          )}
+        </div>
+      )}
+    </PreviewModalShell>
   );
 }

+ 72 - 0
frontend/src/hooks/useElementFullscreen.ts

@@ -0,0 +1,72 @@
+import { useCallback, useEffect, useRef, useState, type RefObject } from 'react';
+
+// True where the unprefixed Fullscreen API can be used on an arbitrary
+// element. iPhone Safari only offers it for <video>, and an <iframe> without
+// allowfullscreen reports fullscreenEnabled === false.
+export function fullscreenApiAvailable(): boolean {
+  return (
+    typeof document !== 'undefined' &&
+    document.fullscreenEnabled === true &&
+    typeof document.documentElement.requestFullscreen === 'function'
+  );
+}
+
+/**
+ * Fullscreen for a preview panel (#2976).
+ *
+ * Uses the Fullscreen API on the element behind `ref` where the browser has
+ * it, and otherwise a viewport-filling layout driven by the same
+ * `isFullscreen` flag, so the toggle keeps working on iPhone Safari and in
+ * embedded views that refuse the request (a webview without the fullscreen
+ * permission rejects it even though `fullscreenEnabled` says yes). In API
+ * mode the flag follows `fullscreenchange`: Esc is handled by the browser and
+ * simply shows up as a change, and the panel leaves fullscreen on unmount so
+ * closing the modal never strands the document in fullscreen.
+ */
+export function useElementFullscreen(ref: RefObject<HTMLElement | null>) {
+  const apiAvailable = fullscreenApiAvailable();
+  const [isFullscreen, setIsFullscreen] = useState(false);
+  // True while the viewport-filling fallback is what the user sees.
+  const fallbackActiveRef = useRef(false);
+
+  useEffect(() => {
+    if (!apiAvailable) return;
+    const element = ref.current;
+    const sync = () => {
+      if (fallbackActiveRef.current) return;
+      setIsFullscreen(document.fullscreenElement != null && document.fullscreenElement === ref.current);
+    };
+    document.addEventListener('fullscreenchange', sync);
+    return () => {
+      document.removeEventListener('fullscreenchange', sync);
+      if (element && document.fullscreenElement === element) {
+        Promise.resolve(document.exitFullscreen()).catch(() => {});
+      }
+    };
+  }, [apiAvailable, ref]);
+
+  const toggleFullscreen = useCallback(() => {
+    if (fallbackActiveRef.current) {
+      fallbackActiveRef.current = false;
+      setIsFullscreen(false);
+      return;
+    }
+    if (apiAvailable && document.fullscreenElement) {
+      Promise.resolve(document.exitFullscreen()).catch(() => {});
+      return;
+    }
+    const element = ref.current;
+    if (!element) return;
+    const enterFallback = () => {
+      fallbackActiveRef.current = true;
+      setIsFullscreen(true);
+    };
+    if (!apiAvailable) {
+      enterFallback();
+      return;
+    }
+    Promise.resolve(element.requestFullscreen()).catch(enterFallback);
+  }, [apiAvailable, ref]);
+
+  return { isFullscreen, toggleFullscreen, apiAvailable };
+}

+ 22 - 0
frontend/src/hooks/usePreviewFullscreen.ts

@@ -0,0 +1,22 @@
+import { useRef, type RefObject } from 'react';
+import { useElementFullscreen } from './useElementFullscreen';
+
+export interface PreviewFullscreen {
+  panelRef: RefObject<HTMLDivElement | null>;
+  isFullscreen: boolean;
+  toggleFullscreen: () => void;
+}
+
+/**
+ * Fullscreen state for a file preview panel (#2976).
+ *
+ * Held by the modal rather than by `PreviewModalShell`, which renders the
+ * toggle: the 3D viewer drives layout effects off `isFullscreen` — in
+ * fullscreen the plate list splits off the canvas — so the flag has to be
+ * readable outside the shell's own subtree.
+ */
+export function usePreviewFullscreen(): PreviewFullscreen {
+  const panelRef = useRef<HTMLDivElement>(null);
+  const { isFullscreen, toggleFullscreen } = useElementFullscreen(panelRef);
+  return { panelRef, isFullscreen, toggleFullscreen };
+}

+ 3 - 0
frontend/src/i18n/locales/de.ts

@@ -4054,6 +4054,9 @@ export default {
       nextPage: 'Nächste Seite',
       zoomIn: 'Vergrößern',
       zoomOut: 'Verkleinern',
+      resetZoom: 'Zoom zurücksetzen',
+      fullscreen: 'Vollbild',
+      exitFullscreen: 'Vollbild beenden',
       emptySheet: 'Dieses Tabellenblatt ist leer',
       truncatedRows: 'Die ersten {{shown}} von {{total}} Zeilen werden angezeigt',
       truncatedCols: 'Die ersten {{shown}} von {{total}} Spalten werden angezeigt',

+ 3 - 0
frontend/src/i18n/locales/en.ts

@@ -4084,6 +4084,9 @@ export default {
       nextPage: 'Next page',
       zoomIn: 'Zoom in',
       zoomOut: 'Zoom out',
+      resetZoom: 'Reset zoom',
+      fullscreen: 'Fullscreen',
+      exitFullscreen: 'Exit fullscreen',
       emptySheet: 'This sheet is empty',
       truncatedRows: 'Showing the first {{shown}} of {{total}} rows',
       truncatedCols: 'Showing the first {{shown}} of {{total}} columns',

+ 3 - 0
frontend/src/i18n/locales/es.ts

@@ -4056,6 +4056,9 @@ export default {
       nextPage: 'Página siguiente',
       zoomIn: 'Acercar',
       zoomOut: 'Alejar',
+      resetZoom: 'Restablecer zoom',
+      fullscreen: 'Pantalla completa',
+      exitFullscreen: 'Salir de la pantalla completa',
       emptySheet: 'Esta hoja está vacía',
       truncatedRows: 'Mostrando las primeras {{shown}} de {{total}} filas',
       truncatedCols: 'Mostrando las primeras {{shown}} de {{total}} columnas',

+ 3 - 0
frontend/src/i18n/locales/fr.ts

@@ -4042,6 +4042,9 @@ export default {
       nextPage: 'Page suivante',
       zoomIn: 'Zoom avant',
       zoomOut: 'Zoom arrière',
+      resetZoom: 'Réinitialiser le zoom',
+      fullscreen: 'Plein écran',
+      exitFullscreen: 'Quitter plein écran',
       emptySheet: 'Cette feuille est vide',
       truncatedRows: 'Affichage des {{shown}} premières lignes sur {{total}}',
       truncatedCols: 'Affichage des {{shown}} premières colonnes sur {{total}}',

+ 3 - 0
frontend/src/i18n/locales/it.ts

@@ -4041,6 +4041,9 @@ export default {
       nextPage: 'Pagina successiva',
       zoomIn: 'Ingrandisci',
       zoomOut: 'Riduci',
+      resetZoom: 'Reimposta zoom',
+      fullscreen: 'Schermo intero',
+      exitFullscreen: 'Esci da schermo intero',
       emptySheet: 'Questo foglio è vuoto',
       truncatedRows: 'Visualizzate le prime {{shown}} righe di {{total}}',
       truncatedCols: 'Visualizzate le prime {{shown}} colonne di {{total}}',

+ 3 - 0
frontend/src/i18n/locales/ja.ts

@@ -4054,6 +4054,9 @@ export default {
       nextPage: '次のページ',
       zoomIn: '拡大',
       zoomOut: '縮小',
+      resetZoom: 'ズームをリセット',
+      fullscreen: 'フルスクリーン',
+      exitFullscreen: 'フルスクリーンを終了',
       emptySheet: 'このシートは空です',
       truncatedRows: '全{{total}}行のうち最初の{{shown}}行を表示しています',
       truncatedCols: '全{{total}}列のうち最初の{{shown}}列を表示しています',

+ 3 - 0
frontend/src/i18n/locales/ko.ts

@@ -3863,6 +3863,9 @@ export default {
       nextPage: '다음 페이지',
       zoomIn: '확대',
       zoomOut: '축소',
+      resetZoom: '확대/축소 초기화',
+      fullscreen: '전체 화면',
+      exitFullscreen: '전체 화면 종료',
       emptySheet: '이 시트는 비어 있습니다',
       truncatedRows: '전체 {{total}}개 행 중 처음 {{shown}}개 표시 중',
       truncatedCols: '전체 {{total}}개 열 중 처음 {{shown}}개 표시 중',

+ 3 - 0
frontend/src/i18n/locales/nl.ts

@@ -4084,6 +4084,9 @@ export default {
       nextPage: 'Volgende pagina',
       zoomIn: 'Inzoomen',
       zoomOut: 'Uitzoomen',
+      resetZoom: 'Zoom herstellen',
+      fullscreen: 'Volledig scherm',
+      exitFullscreen: 'Volledig scherm afsluiten',
       emptySheet: 'Dit werkblad is leeg',
       truncatedRows: 'Eerste {{shown}} van {{total}} rijen worden getoond',
       truncatedCols: 'Eerste {{shown}} van {{total}} kolommen worden getoond',

+ 3 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -4041,6 +4041,9 @@ export default {
       nextPage: 'Próxima página',
       zoomIn: 'Aumentar zoom',
       zoomOut: 'Diminuir zoom',
+      resetZoom: 'Redefinir zoom',
+      fullscreen: 'Tela Cheia',
+      exitFullscreen: 'Sair da Tela Cheia',
       emptySheet: 'Esta planilha está vazia',
       truncatedRows: 'Mostrando as primeiras {{shown}} de {{total}} linhas',
       truncatedCols: 'Mostrando as primeiras {{shown}} de {{total}} colunas',

+ 3 - 0
frontend/src/i18n/locales/ru.ts

@@ -3855,6 +3855,9 @@ export default {
       nextPage: 'Следующая страница',
       zoomIn: 'Увеличить',
       zoomOut: 'Уменьшить',
+      resetZoom: 'Сбросить масштаб',
+      fullscreen: 'На весь экран',
+      exitFullscreen: 'Выйти из полноэкранного режима',
       emptySheet: 'Этот лист пуст',
       truncatedRows: 'Показаны первые {{shown}} из {{total}} строк',
       truncatedCols: 'Показаны первые {{shown}} из {{total}} столбцов',

+ 3 - 0
frontend/src/i18n/locales/sv.ts

@@ -4083,6 +4083,9 @@ errors: {
       nextPage: 'Nästa sida',
       zoomIn: 'Zooma in',
       zoomOut: 'Zooma ut',
+      resetZoom: 'Återställ zoom',
+      fullscreen: 'Helskärm',
+      exitFullscreen: 'Avsluta helskärm',
       emptySheet: 'Det här bladet är tomt',
       truncatedRows: 'Visar de första {{shown}} av {{total}} raderna',
       truncatedCols: 'Visar de första {{shown}} av {{total}} kolumnerna',

+ 3 - 0
frontend/src/i18n/locales/tr.ts

@@ -4048,6 +4048,9 @@ export default {
       nextPage: 'Sonraki sayfa',
       zoomIn: 'Yakınlaştır',
       zoomOut: 'Uzaklaştır',
+      resetZoom: 'Yakınlaştırmayı sıfırla',
+      fullscreen: 'Tam ekran',
+      exitFullscreen: 'Tam ekrandan çık',
       emptySheet: 'Bu sayfa boş',
       truncatedRows: '{{total}} satırdan ilk {{shown}} tanesi gösteriliyor',
       truncatedCols: '{{total}} sütundan ilk {{shown}} tanesi gösteriliyor',

+ 3 - 0
frontend/src/i18n/locales/uk.ts

@@ -4081,6 +4081,9 @@ export default {
       nextPage: 'Наступна сторінка',
       zoomIn: 'Збільшити',
       zoomOut: 'Зменшити',
+      resetZoom: 'Скинути масштаб',
+      fullscreen: 'Повний екран',
+      exitFullscreen: 'Вийти з повноекранного режиму',
       emptySheet: 'Цей аркуш порожній',
       truncatedRows: 'Показано перші {{shown}} з {{total}} рядків',
       truncatedCols: 'Показано перші {{shown}} з {{total}} стовпців',

+ 3 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -4041,6 +4041,9 @@ export default {
       nextPage: '下一页',
       zoomIn: '放大',
       zoomOut: '缩小',
+      resetZoom: '重置缩放',
+      fullscreen: '全屏',
+      exitFullscreen: '退出全屏',
       emptySheet: '此工作表为空',
       truncatedRows: '仅显示前 {{shown}} 行,共 {{total}} 行',
       truncatedCols: '仅显示前 {{shown}} 列,共 {{total}} 列',

+ 3 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -4041,6 +4041,9 @@ export default {
       nextPage: '下一頁',
       zoomIn: '放大',
       zoomOut: '縮小',
+      resetZoom: '重設縮放',
+      fullscreen: '全螢幕',
+      exitFullscreen: '離開全螢幕',
       emptySheet: '此工作表是空的',
       truncatedRows: '顯示前 {{shown}} 列,共 {{total}} 列',
       truncatedCols: '顯示前 {{shown}} 欄,共 {{total}} 欄',

+ 146 - 56
frontend/src/pages/FileManagerPage.tsx

@@ -51,6 +51,7 @@ import {
   Globe,
   StickyNote,
   Camera,
+  Eye,
 } from 'lucide-react';
 import { api } from '../api/client';
 import type {
@@ -97,6 +98,9 @@ const PdfPreviewModal = lazy(() =>
 const SpreadsheetPreviewModal = lazy(() =>
   import('../components/SpreadsheetPreviewModal').then((m) => ({ default: m.SpreadsheetPreviewModal }))
 );
+const ImagePreviewModal = lazy(() =>
+  import('../components/ImagePreviewModal').then((m) => ({ default: m.ImagePreviewModal }))
+);
 
 function isSpreadsheetType(fileType: string): boolean {
   return fileType === 'csv' || fileType === 'xlsx' || fileType === 'ods';
@@ -106,6 +110,68 @@ function isStepType(fileType: string): boolean {
   return fileType === 'step' || fileType === 'stp';
 }
 
+// Mirrors IMAGE_EXTENSIONS in routes/library.py — the types the server both
+// stores and renders a thumbnail for, and so the ones that get an image icon.
+const IMAGE_TYPES = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff', 'tif']);
+
+// The subset ImagePreviewModal can actually show: it hands the bytes to an
+// <img>, and outside Safari no browser decodes TIFF. Offering the preview
+// would download up to 50 MB only to report "cannot be previewed", so TIFF
+// keeps its server-rendered thumbnail and no preview (#2976).
+const PREVIEWABLE_IMAGE_TYPES = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']);
+
+function isImageType(fileType: string): boolean {
+  return IMAGE_TYPES.has(fileType.toLowerCase());
+}
+
+function isPreviewableImageType(fileType: string): boolean {
+  return PREVIEWABLE_IMAGE_TYPES.has(fileType.toLowerCase());
+}
+
+// Which files have a preview at all: what a double-click opens, and what the
+// toolbar's Preview button appears for (#2976). Sliced files go to the
+// full-page gcode viewer, everything else to a modal.
+function isPreviewableLibraryFile(file: LibraryFileListItem): boolean {
+  const type = file.file_type;
+  return (
+    isSlicedLibraryFile(file) ||
+    type === '3mf' ||
+    type === 'stl' ||
+    isStepType(type) ||
+    type === 'pdf' ||
+    isSpreadsheetType(type) ||
+    isPreviewableImageType(type)
+  );
+}
+
+// Spread onto a card/row subtree that is not "the row": its own controls must
+// neither toggle the selection nor open the preview. `dblclick` is a separate
+// native event from `click`, so stopping the click alone still lets the second
+// click of a double-click reach the row's onDoubleClick (#2976).
+const stopRowActivation = {
+  onClick: (e: React.MouseEvent) => e.stopPropagation(),
+  onDoubleClick: (e: React.MouseEvent) => e.stopPropagation(),
+};
+
+// Whether the preview is the 3D one, which has its own menu label.
+function isModelPreview(file: LibraryFileListItem): boolean {
+  return isSlicedLibraryFile(file) || file.file_type === '3mf' || file.file_type === 'stl' || isStepType(file.file_type);
+}
+
+function documentPreviewIcon(fileType: string) {
+  if (fileType === 'pdf') return <FileText className="w-4 h-4" />;
+  if (isSpreadsheetType(fileType)) return <FileSpreadsheet className="w-4 h-4" />;
+  return <Image className="w-4 h-4" />;
+}
+
+// Types the server renders a thumbnail for on request, STL through trimesh
+// and PDF through PDFium (#2976), so the per-file "Generate thumbnail" action
+// applies to them. Mirrors the file types batch_generate_stl_thumbnails in
+// routes/library.py selects.
+function hasServerThumbnail(fileType: string): boolean {
+  return fileType === 'stl' || fileType === 'pdf';
+}
+
 // New Folder Modal
 interface NewFolderModalProps {
   parentId: number | null;
@@ -784,8 +850,7 @@ interface FileCardProps {
   // (#3029), so offering one there would only ever fail.
   desktopSlicer: SlicerType;
   canSlice?: boolean;
-  onPreview3d?: (file: LibraryFileListItem) => void;
-  onPreviewDocument?: (file: LibraryFileListItem) => void;
+  onPreview?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
   onDetails?: (file: LibraryFileListItem) => void;
   onGenerateThumbnail?: (file: LibraryFileListItem) => void;
@@ -798,7 +863,7 @@ interface FileCardProps {
   t: TFunction;
 }
 
-function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, desktopSlicer, canSlice, onPreview3d, onPreviewDocument, onRename, onDetails, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
+function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, desktopSlicer, canSlice, onPreview, onRename, onDetails, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
   // Viewport coordinates rather than a flag, because the menu is rendered by
   // `ContextMenu` at `position: fixed` and anchored to the button (#2846). The
   // card it belongs to is only ~270px tall for a bare STL, which is shorter
@@ -840,20 +905,12 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
       title: !hasPermission('pipelines:run') ? t('library.runWithPipeline.noPermission') : undefined,
     });
   }
-  if (onPreview3d && (file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl' || file.file_type === 'gcode.3mf' || isStepType(file.file_type))) {
-    menuItems.push({
-      label: t('fileManager.preview3d'),
-      icon: <Box className="w-4 h-4" />,
-      onClick: () => onPreview3d(file),
-      disabled: !canPreview3d,
-      title: !canPreview3d ? t('fileManager.noPermissionPreview') : undefined,
-    });
-  }
-  if (onPreviewDocument && (file.file_type === 'pdf' || isSpreadsheetType(file.file_type))) {
+  if (onPreview && isPreviewableLibraryFile(file)) {
+    const modelPreview = isModelPreview(file);
     menuItems.push({
-      label: t('fileManager.preview.open'),
-      icon: file.file_type === 'pdf' ? <FileText className="w-4 h-4" /> : <FileSpreadsheet className="w-4 h-4" />,
-      onClick: () => onPreviewDocument(file),
+      label: modelPreview ? t('fileManager.preview3d') : t('fileManager.preview.open'),
+      icon: modelPreview ? <Box className="w-4 h-4" /> : documentPreviewIcon(file.file_type),
+      onClick: () => onPreview(file),
       disabled: !canPreview3d,
       title: !canPreview3d ? t('fileManager.noPermissionPreview') : undefined,
     });
@@ -892,7 +949,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
       onClick: () => window.open(file.external_url!, '_blank', 'noopener,noreferrer'),
     });
   }
-  if (onGenerateThumbnail && file.file_type === 'stl') {
+  if (onGenerateThumbnail && hasServerThumbnail(file.file_type)) {
     menuItems.push({
       label: t('fileManager.generateThumbnail'),
       icon: <Image className="w-4 h-4" />,
@@ -918,6 +975,9 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
           : 'border-bambu-dark-tertiary hover:border-bambu-green/50'
       }`}
       onClick={() => onSelect(file.id)}
+      // Double-click opens the preview (#2976). The two clicks that precede it
+      // toggle the selection twice, so the selection is left as it was.
+      onDoubleClick={() => onPreview?.(file)}
     >
       {/* Thumbnail */}
       <div className="aspect-square bg-bambu-dark flex items-center justify-center overflow-hidden rounded-t-lg">
@@ -931,6 +991,8 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
           <FileText className="w-12 h-12 text-bambu-gray/30" />
         ) : isSpreadsheetType(file.file_type) ? (
           <FileSpreadsheet className="w-12 h-12 text-bambu-gray/30" />
+        ) : isImageType(file.file_type) ? (
+          <Image className="w-12 h-12 text-bambu-gray/30" />
         ) : (
           <FileBox className="w-12 h-12 text-bambu-gray/30" />
         )}
@@ -986,7 +1048,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
         {/* Metadata indicators (#3077): link, notes, photos. The link opens in
             a new tab like the archive card's globe; the others open Details. */}
         {(file.external_url || file.has_notes || (file.photo_count ?? 0) > 0) && (
-          <div className="mt-1 flex items-center gap-2 text-xs text-bambu-gray" onClick={(e) => e.stopPropagation()}>
+          <div className="mt-1 flex items-center gap-2 text-xs text-bambu-gray" {...stopRowActivation}>
             {file.external_url && (
               <a
                 href={file.external_url}
@@ -1039,7 +1101,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
           </div>
         )}
         {(file.tags?.length ?? 0) > 0 && (
-          <div className="mt-2 flex flex-wrap gap-1" onClick={(e) => e.stopPropagation()}>
+          <div className="mt-2 flex flex-wrap gap-1" {...stopRowActivation}>
             {file.tags!.map((tg) => (
               <button
                 key={tg.id}
@@ -1057,7 +1119,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
       </div>
 
       {/* Actions - hover-revealed with a mouse, always there without one (#2865) */}
-      <div className="absolute bottom-2 right-2 transition-opacity can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100" onClick={(e) => e.stopPropagation()}>
+      <div className="absolute bottom-2 right-2 transition-opacity can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100" {...stopRowActivation}>
         <button
           onClick={(e) => {
             // No open/close toggle: the menu's own outside-mousedown handler
@@ -1130,6 +1192,7 @@ export function FileManagerPage() {
   const [pdfPreviewFile, setPdfPreviewFile] = useState<LibraryFileListItem | null>(null);
   const [sheetPreviewFile, setSheetPreviewFile] = useState<LibraryFileListItem | null>(null);
   const [detailsFile, setDetailsFile] = useState<LibraryFileListItem | null>(null);
+  const [imagePreviewFile, setImagePreviewFile] = useState<LibraryFileListItem | null>(null);
   const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
     return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
   });
@@ -1676,6 +1739,34 @@ export function FileManagerPage() {
     onError: (error: Error) => showToast(error.message, 'error'),
   });
 
+  // The one way into a preview (#2976): the kebab entry, the action-strip
+  // icon, a double-click on the card or row, and the toolbar button all end
+  // up here. Sliced files open the full-page gcode viewer the archive card
+  // uses; everything else opens the modal for its type. A file with no
+  // preview does nothing.
+  const openPreview = useCallback((file: LibraryFileListItem) => {
+    if (!hasPermission('library:read')) return;
+    if (isSlicedLibraryFile(file)) {
+      navigate(`/gcode-viewer?library_file=${file.id}`);
+    } else if (file.file_type === '3mf' || file.file_type === 'stl' || isStepType(file.file_type)) {
+      setViewerFile(file);
+    } else if (file.file_type === 'pdf') {
+      setPdfPreviewFile(file);
+    } else if (isSpreadsheetType(file.file_type)) {
+      setSheetPreviewFile(file);
+    } else if (isPreviewableImageType(file.file_type)) {
+      setImagePreviewFile(file);
+    }
+  }, [hasPermission, navigate]);
+
+  // The toolbar's Preview button acts on one file, so it is offered only for
+  // a single previewable selection.
+  const previewSelection = useMemo(() => {
+    if (!files || selectedFiles.length !== 1) return null;
+    const file = files.find((f) => f.id === selectedFiles[0]);
+    return file && isPreviewableLibraryFile(file) ? file : null;
+  }, [files, selectedFiles]);
+
   // Get sliced files from selection
   const selectedSlicedFiles = useMemo(() => {
     if (!files) return [];
@@ -2411,6 +2502,18 @@ export function FileManagerPage() {
                   </span>
                   <div className="hidden sm:block flex-1" />
                   <div className="w-full sm:w-auto flex flex-wrap items-center gap-2 mt-2 sm:mt-0">
+                    {previewSelection && (
+                      <Button
+                        variant="secondary"
+                        size="sm"
+                        onClick={() => openPreview(previewSelection)}
+                        disabled={!hasPermission('library:read')}
+                        title={!hasPermission('library:read') ? t('fileManager.noPermissionPreview') : undefined}
+                      >
+                        <Eye className="w-4 h-4 sm:mr-1" />
+                        <span className="hidden sm:inline">{t('fileManager.preview.open')}</span>
+                      </Button>
+                    )}
                     {/* Print used to disappear the moment a second sliced file was
                         selected. Selecting several is now how you say "same job,
                         different printers" (#671) — one queue item, whichever
@@ -2564,21 +2667,7 @@ export function FileManagerPage() {
                     onRunPipeline={setRunPipelineFile}
                     useSlicerApi={settings?.use_slicer_api ?? false}
                     canSlice={canSlice()}
-                    onPreview3d={(f) => {
-                      // Sliced files (.gcode / .gcode.3mf) open the same
-                      // full-page gcode viewer the archive card uses, so
-                      // the two paths feel consistent. STL / source 3MF
-                      // continue to use the in-app 3D model viewer modal.
-                      if (isSlicedLibraryFile(f)) {
-                        navigate(`/gcode-viewer?library_file=${f.id}`);
-                      } else {
-                        setViewerFile(f);
-                      }
-                    }}
-                    onPreviewDocument={(f) => {
-                      if (f.file_type === 'pdf') setPdfPreviewFile(f);
-                      else setSheetPreviewFile(f);
-                    }}
+                    onPreview={openPreview}
                     onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
                     onDetails={setDetailsFile}
                     onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
@@ -2624,6 +2713,8 @@ export function FileManagerPage() {
                       selectedFiles.includes(file.id) ? 'bg-bambu-green/10' : ''
                     }`}
                     onClick={() => handleFileSelect(file.id)}
+                    // Double-click opens the preview (#2976), as in the grid.
+                    onDoubleClick={() => openPreview(file)}
                   >
                     {/* Checkbox */}
                     <div className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
@@ -2649,6 +2740,8 @@ export function FileManagerPage() {
                                 <FileText className="w-5 h-5 text-bambu-gray/50" />
                               ) : isSpreadsheetType(file.file_type) ? (
                                 <FileSpreadsheet className="w-5 h-5 text-bambu-gray/50" />
+                              ) : isImageType(file.file_type) ? (
+                                <Image className="w-5 h-5 text-bambu-gray/50" />
                               ) : (
                                 <FileBox className="w-5 h-5 text-bambu-gray/50" />
                               )}
@@ -2677,7 +2770,7 @@ export function FileManagerPage() {
                               href={file.external_url}
                               target="_blank"
                               rel="noopener noreferrer"
-                              onClick={(e) => e.stopPropagation()}
+                              {...stopRowActivation}
                               className="flex-shrink-0 text-bambu-gray hover:text-bambu-green"
                               title={t('fileManager.details.openLink')}
                               aria-label={t('fileManager.details.openLink')}
@@ -2742,7 +2835,7 @@ export function FileManagerPage() {
                         filter; minmax(0,200px) on the column lets the cell
                         shrink/wrap on narrow viewports without pushing the
                         Actions cell off-screen. */}
-                    <div className="min-w-0" onClick={(e) => e.stopPropagation()}>
+                    <div className="min-w-0" {...stopRowActivation}>
                       {!file.tags || file.tags.length === 0 ? (
                         <span className="text-xs text-bambu-gray/50">-</span>
                       ) : (
@@ -2763,7 +2856,7 @@ export function FileManagerPage() {
                       )}
                     </div>
                     {/* Actions */}
-                    <div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
+                    <div className="flex items-center gap-1" {...stopRowActivation}>
                       {isSlicedLibraryFile(file) && (
                         <>
                           <button
@@ -2811,16 +2904,9 @@ export function FileManagerPage() {
                           <Play className="w-4 h-4" />
                         </button>
                       )}
-                      {(file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'gcode.3mf' || file.file_type === 'stl' || isStepType(file.file_type)) && (
+                      {isModelPreview(file) && (
                         <button
-                          onClick={() => {
-                            if (!hasPermission('library:read')) return;
-                            if (isSlicedLibraryFile(file)) {
-                              navigate(`/gcode-viewer?library_file=${file.id}`);
-                            } else {
-                              setViewerFile(file);
-                            }
-                          }}
+                          onClick={() => openPreview(file)}
                           className={`p-1.5 rounded transition-colors ${
                             hasPermission('library:read')
                               ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
@@ -2832,13 +2918,9 @@ export function FileManagerPage() {
                           <Box className="w-4 h-4" />
                         </button>
                       )}
-                      {(file.file_type === 'pdf' || isSpreadsheetType(file.file_type)) && (
+                      {!isModelPreview(file) && isPreviewableLibraryFile(file) && (
                         <button
-                          onClick={() => {
-                            if (!hasPermission('library:read')) return;
-                            if (file.file_type === 'pdf') setPdfPreviewFile(file);
-                            else setSheetPreviewFile(file);
-                          }}
+                          onClick={() => openPreview(file)}
                           className={`p-1.5 rounded transition-colors ${
                             hasPermission('library:read')
                               ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
@@ -2847,7 +2929,7 @@ export function FileManagerPage() {
                           title={hasPermission('library:read') ? t('fileManager.preview.open') : t('fileManager.noPermissionPreview')}
                           disabled={!hasPermission('library:read')}
                         >
-                          {file.file_type === 'pdf' ? <FileText className="w-4 h-4" /> : <FileSpreadsheet className="w-4 h-4" />}
+                          {documentPreviewIcon(file.file_type)}
                         </button>
                       )}
                       <button
@@ -2886,7 +2968,7 @@ export function FileManagerPage() {
                       >
                         <Pencil className="w-4 h-4" />
                       </button>
-                      {file.file_type === 'stl' && (
+                      {hasServerThumbnail(file.file_type) && (
                         <button
                           onClick={() => canModify('library', 'update', file.created_by_id) && singleThumbnailMutation.mutate(file.id)}
                           className={`p-1.5 rounded transition-colors ${
@@ -3087,7 +3169,7 @@ export function FileManagerPage() {
         />
       )}
 
-      {(pdfPreviewFile || sheetPreviewFile) && (
+      {(pdfPreviewFile || sheetPreviewFile || imagePreviewFile) && (
         <Suspense fallback={null}>
           {pdfPreviewFile && (
             <PdfPreviewModal
@@ -3108,6 +3190,14 @@ export function FileManagerPage() {
               onSnapshot={previewSnapshotHandler(sheetPreviewFile)}
             />
           )}
+          {imagePreviewFile && (
+            <ImagePreviewModal
+              libraryFileId={imagePreviewFile.id}
+              filename={imagePreviewFile.print_name || imagePreviewFile.filename}
+              fileSize={imagePreviewFile.file_size}
+              onClose={() => setImagePreviewFile(null)}
+            />
+          )}
         </Suspense>
       )}
 

+ 43 - 1
frontend/vite.config.ts

@@ -1,4 +1,7 @@
+import { createRequire } from 'node:module'
+import { readdirSync, readFileSync } from 'node:fs'
 import { defineConfig } from 'vite'
+import type { Plugin } from 'vite'
 import react from '@vitejs/plugin-react'
 import path from 'path'
 
@@ -6,6 +9,45 @@ import path from 'path'
 const backendPort = process.env.BACKEND_PORT || '8000'
 const backendUrl = `http://localhost:${backendPort}`
 
+// pdf.js keeps these out of its bundle and fetches them at runtime (#2976):
+// CJK text needs the CMaps, non-embedded fonts the standard font files, and
+// JPEG2000/JBIG2 images and ICC colour the wasm decoders. Published under the
+// bundle's own assets directory so the existing /assets mount serves them —
+// PdfPreviewModal builds the matching URLs from its PDFJS_ASSET_BASE.
+const PDFJS_RUNTIME_DIRS = ['cmaps', 'iccs', 'standard_fonts', 'wasm']
+const PDFJS_RUNTIME_PREFIX = 'assets/pdfjs'
+
+/** Publish pdf.js's runtime data directories next to the bundle. */
+function pdfjsRuntimeAssets(): Plugin {
+  const require = createRequire(__filename)
+  const packageDir = path.dirname(require.resolve('pdfjs-dist/package.json'))
+  // Published path -> file on disk. Built once, and used as an allowlist by
+  // the dev-server handler so no request can escape the package directory.
+  const files = new Map<string, string>()
+  for (const dir of PDFJS_RUNTIME_DIRS) {
+    for (const entry of readdirSync(path.join(packageDir, dir), { withFileTypes: true })) {
+      if (!entry.isFile()) continue
+      files.set(`${PDFJS_RUNTIME_PREFIX}/${dir}/${entry.name}`, path.join(packageDir, dir, entry.name))
+    }
+  }
+
+  return {
+    name: 'bambuddy:pdfjs-runtime-assets',
+    generateBundle() {
+      for (const [fileName, source] of files) {
+        this.emitFile({ type: 'asset', fileName, source: readFileSync(source) })
+      }
+    },
+    configureServer(server) {
+      server.middlewares.use((req, res, next) => {
+        const source = files.get((req.url ?? '').split('?')[0].replace(/^\//, ''))
+        if (!source) return next()
+        res.setHeader('Content-Type', source.endsWith('.wasm') ? 'application/wasm' : 'application/octet-stream')
+        res.end(readFileSync(source))
+      })
+    },
+  }
+}
 
 export default defineConfig({
   // Default base ('/') emits absolute asset URLs (/assets/...). Required so
@@ -17,7 +59,7 @@ export default defineConfig({
   // fix for subpath reverse proxies (#1195, wontfix) is reverted — that
   // audience uses NPM + Cloudflare Tunnel at a real domain per the
   // documented workaround, which doesn't depend on this setting.
-  plugins: [react()],
+  plugins: [react(), pdfjsRuntimeAssets()],
   build: {
     outDir: '../static',
     emptyOutDir: true,