diff --git a/tests/document-viewer-shell.dom.test.tsx b/tests/document-viewer-shell.dom.test.tsx
new file mode 100644
index 00000000..c4751bc8
--- /dev/null
+++ b/tests/document-viewer-shell.dom.test.tsx
@@ -0,0 +1,150 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+// DocumentViewer resolves a four-way shell state (loading / ready / auth-required
+// / error) that decides whether a source document is shown at all. The
+// auth-required branch is the private-document gate: an unauthenticated reader
+// must get the sign-in shell, never document content. The state is prop-drivable
+// via initialDetail / initialError, so these tests pin it without a network.
+
+const { push } = vi.hoisted(() => ({ push: vi.fn() }));
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push, replace: vi.fn(), refresh: vi.fn(), back: vi.fn(), forward: vi.fn(), prefetch: vi.fn() }),
+ useSearchParams: () => new URLSearchParams(),
+ usePathname: () => "/documents/doc-1",
+}));
+
+vi.mock("@/lib/supabase/client", () => ({
+ useAuthSession: () => ({
+ status: "signed_out",
+ session: null,
+ isConfigured: true,
+ authorizationHeader: {},
+ registerAuthRequest: () => ({ epoch: 1, release: vi.fn() }),
+ isAuthEpochCurrent: () => true,
+ markSessionExpired: vi.fn(),
+ signInWithEmail: vi.fn(),
+ signInWithPassword: vi.fn(),
+ signUpWithPassword: vi.fn(),
+ signInWithOAuth: vi.fn(),
+ signOut: vi.fn(),
+ }),
+}));
+
+// Every document request is gated on a local-project identity probe
+// (/api/local-project-id). No server answers it in a unit run, and when the
+// probe fails the viewer replaces the error under test with its own
+// "unsafe local project" message — which is exactly why this file passed on a
+// workstation with the dev server up and failed in CI. Stub it as a safe local
+// origin so the shell state, not the environment, decides the outcome.
+vi.mock("@/lib/local-project-identity", () => ({
+ readLocalProjectIdentity: async () => ({ localServer: { safeLocalOrigin: true } }),
+ unsafeLocalProjectMessage: () => "This local server does not belong to this project.",
+}));
+
+// The preview is next/dynamic-loaded and never mounts synchronously in jsdom;
+// it is mocked defensively so a late resolve cannot pull a real canvas into the
+// test environment. The shell state, not the raster preview, is under test.
+vi.mock("@/components/document-viewer/pdf-canvas-viewer", () => ({
+ PdfCanvasViewer: () => null,
+ NativePdfEmbed: () => null,
+}));
+
+import { DocumentViewer } from "@/components/DocumentViewer";
+import type { DocumentDetailPayload } from "@/lib/document-detail-contract";
+
+function detailPayload() {
+ return {
+ document: {
+ id: "doc-1",
+ title: "Clozapine titration guideline",
+ description: null,
+ file_name: "clozapine-titration.pdf",
+ file_type: "application/pdf",
+ file_size: 204800,
+ storage_path: "documents/doc-1/clozapine-titration.pdf",
+ status: "indexed",
+ page_count: 4,
+ chunk_count: 8,
+ image_count: 0,
+ error_message: null,
+ updated_at: "2026-01-01T00:00:00.000Z",
+ created_at: "2026-01-01T00:00:00.000Z",
+ labels: [],
+ metadata: {},
+ summary: null,
+ },
+ pages: [],
+ images: [],
+ tableFacts: [],
+ chunks: [],
+ demoMode: true,
+ assetScope: "document",
+ window: {
+ requestedPage: 1,
+ effectivePage: 1,
+ selectedChunkId: null,
+ pages: { from: 1, to: 4, limit: 4, total: 4, hasBefore: false, hasAfter: false },
+ chunks: { offset: 0, limit: 8, total: 8, hasBefore: false, hasAfter: false, selectedChunkId: null },
+ },
+ pageWindow: { from: 1, to: 4, limit: 4, total: 4, hasBefore: false, hasAfter: false },
+ chunkWindow: { offset: 0, limit: 8, total: 8, hasBefore: false, hasAfter: false, selectedChunkId: null },
+ } satisfies DocumentDetailPayload;
+}
+
+// In demo / local-no-auth mode every document is public, so the private-access
+// gate is deliberately inert and the sign-in shell can never render. Whichever
+// of those a runner happens to export would silently turn the gate assertion
+// into a no-op, so pin both off — this test is about the gate, not the mode.
+beforeEach(() => {
+ vi.stubEnv("NEXT_PUBLIC_DEMO_MODE", "false");
+ vi.stubEnv("NEXT_PUBLIC_LOCAL_NO_AUTH", "false");
+});
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.clearAllMocks();
+});
+
+describe("DocumentViewer — shell states", () => {
+ it("shows the sign-in shell (never document content) when private access is required", async () => {
+ render(
+ ,
+ );
+
+ expect(await screen.findByText("Sign in required")).toBeVisible();
+ expect(screen.getByText("Sign in to open private source documents.")).toBeVisible();
+ // The private-access gate must resolve to its own shell, not the generic
+ // failure shell (which would read as "broken" rather than "sign in").
+ expect(screen.queryByText("Source unavailable")).toBeNull();
+ });
+
+ it("shows the unavailable shell with the failure reason for a generic load error", async () => {
+ render();
+
+ expect(await screen.findByText("Source unavailable")).toBeVisible();
+ expect(screen.getByText("Document could not be loaded.")).toBeVisible();
+ expect(screen.queryByText("Sign in required")).toBeNull();
+ });
+
+ it("shows the ready shell with the document identity when a detail payload is supplied", async () => {
+ render();
+
+ // The display title is smart-cased from "Clozapine titration guideline" and
+ // rendered in the header h1. The exact filename is visible only inside the
+ // document actions sheet, opened by the "Open document actions" button.
+ const heading = await screen.findByRole("heading", { level: 1, name: "Clozapine Titration Guideline" });
+ expect(heading).toBeVisible();
+
+ // Open the document actions sheet and verify the exact filename is visible.
+ // There are two "Open document actions" buttons (header and floating composer);
+ // click the first one (header button) to open the actions sheet.
+ const actionsButtons = screen.getAllByRole("button", { name: "Open document actions" });
+ fireEvent.click(actionsButtons[0]);
+ expect(await screen.findByText("clozapine-titration.pdf")).toBeVisible();
+
+ // A supplied payload must resolve to the ready shell — neither failure shell.
+ expect(screen.queryByText("Source unavailable")).toBeNull();
+ expect(screen.queryByText("Sign in required")).toBeNull();
+ });
+});