From fcde1fd37b0fbb9664ab35c0ddc40dd75c435084 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Tue, 25 Aug 2026 13:45:57 -0400 Subject: [PATCH] =?UTF-8?q?feat(ui):=20documents=20open=20in=20an=20almost?= =?UTF-8?q?-fullscreen=20modal=20instead=20of=20a=20new=20page=20=E2=80=94?= =?UTF-8?q?=20same-page=20overlay=20on=20chat=20+=20Sources,=20/document.h?= =?UTF-8?q?tml=20kept=20as=20the=20no-JS/direct-link=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../26_document_modal_viewer/00_phase.md | 44 +++ .../01_modal_css_and_html.md | 62 ++++ .../02_app_js_modal_intercept.md | 40 +++ .../03_document_js_modal_mode.md | 29 ++ .../04_e2e_regression_suite.md | 33 ++ frontend/assets/app.js | 94 +++-- frontend/assets/document-modal.js | 200 +++++++++++ frontend/assets/document.js | 232 +++++++------ frontend/assets/sources.js | 30 +- frontend/assets/styles.css | 167 +++++++++ frontend/index.html | 32 ++ frontend/sources.html | 39 ++- tests/e2e/test_chat_persistence.py | 2 +- tests/e2e/test_chat_rag.py | 8 +- tests/e2e/test_document_back_navigation.py | 71 ++-- tests/e2e/test_document_viewer.py | 323 +++++++++++++----- tests/integration/test_api.py | 2 + tests/unit/test_document_viewer.py | 157 ++++++++- 18 files changed, 1307 insertions(+), 258 deletions(-) create mode 100644 .agent/phases/complete/26_document_modal_viewer/00_phase.md create mode 100644 .agent/phases/complete/26_document_modal_viewer/01_modal_css_and_html.md create mode 100644 .agent/phases/complete/26_document_modal_viewer/02_app_js_modal_intercept.md create mode 100644 .agent/phases/complete/26_document_modal_viewer/03_document_js_modal_mode.md create mode 100644 .agent/phases/complete/26_document_modal_viewer/04_e2e_regression_suite.md create mode 100644 frontend/assets/document-modal.js diff --git a/.agent/phases/complete/26_document_modal_viewer/00_phase.md b/.agent/phases/complete/26_document_modal_viewer/00_phase.md new file mode 100644 index 0000000..76ea76e --- /dev/null +++ b/.agent/phases/complete/26_document_modal_viewer/00_phase.md @@ -0,0 +1,44 @@ +# Phase 26 — Document Modal Viewer + +**Source:** `TODO.md L4 — "New documents should open in an almost-fullscreen modal, not in a new page"` +**Story:** `.agent/user_stories/document-modal.md` +**Context:** Phase 10 added the separate `/document.html` viewer page; phase 19 added the shared header bar that now lives on every page. The document content is served by the stateless `GET /api/documents/content` endpoint (PLAN §4). + +## Objective +Stop opening cited documents in a new page/tab. Clicking a source chip or a Sources-table path link now opens the document in an **almost-fullscreen modal overlay** on the current page, fed by the same `/api/documents/content` endpoint. The existing `/document.html` page stays as the no-JS / direct-link fallback and its behaviour is unchanged. + +## Dependencies +- `10_story_document_viewer` (complete) — the `/document.html` page, the `document.js` renderer, the `renderMarkdown` escape-first renderer in `markdown.js`, and the `#doc-content` / `.doc-md` / `.doc-raw` markup this phase reuses inside the modal. +- `19_shared_header` (complete) — the shared header bar the modal sits under; the modal must not disturb the header. +- `08_story_dark_tech_theme` (complete) — the Phase-08 tokens and the ≥4.5:1 contrast / `prefers-reduced-motion` contract the modal must honour. + +## Tasks +1. `01_modal_css_and_html.md` — the modal CSS (overlay, backdrop, close button, scrollable content area) + inject the modal skeleton into `index.html` +2. `02_app_js_modal_intercept.md` — intercept document links in `app.js` + `sources.js`, fetch content via `/api/documents/content`, render inside the modal +3. `03_document_js_modal_mode.md` — adapt `document.js` to optionally render in modal mode (reuse the same API call) for the direct-link fallback path +4. `04_e2e_regression_suite.md` — update `test_document_viewer.py` to verify modal behaviour; the story gate, run in isolation + +## Testing & Quality +- Unit/integration: none required for the modal itself (frontend-only); the `/api/documents/content` endpoint is unchanged (no `app/` change → no coverage delta). +- Coverage: frontend-only; the >90% `app/` gate is unaffected. +- E2E: `tests/e2e/test_document_viewer.py` rewritten for the modal contract (task 4), green **in isolation** (prereq `podman compose up -d db`). + +## Completion Criteria +- [ ] Clicking a source chip (chat) or a Sources-table path link opens the document in an almost-fullscreen modal on the **same page** (no new tab, no navigation). +- [ ] The modal renders the same content the `/document.html` page renders: md/markdown via the shared renderer (`.doc-md`), other formats in `
`, source/format/path/indexed/chunks meta.
+- [ ] The modal has a visible close control, closes on Escape, closes on backdrop click, and keeps the dark theme + a11y frame (skip-link, focus trap, `:focus-visible`, aria-label).
+- [ ] The existing `/document.html` page still works unchanged (direct link, back button, XSS-safe rendering, not-found state).
+- [ ] No CDN tags on any touched page; every asset reference is same-origin or `data:`.
+- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL ≥ pre-change number (gate >90%).
+- [ ] `uv run pytest tests/e2e/test_document_viewer.py -v --no-cov` green in isolation.
+- [ ] `uv run ruff check . && uv run pyright` clean.
+- [ ] UI Structure Check (AGENTS.md rule 5): modal content uses the standard centered column width for md; backdrop behind content; no 360px overflow.
+- [ ] `.agent/user_stories/document-modal.md` exists.
+- [ ] One `--no-gpg-sign` commit staging only this phase's files; `.agent/phases/todo/26_document_modal_viewer/` moved to `.agent/phases/complete/`.
+
+## Locked decisions
+- **No backend change** — the modal reuses `GET /api/documents/content` unchanged (A10 untouched: the API stays stateless).
+- **A11 untouched** — vanilla HTML/CSS/JS, no CDN, zero new packages, no new assets, system font stack; the modal is pure CSS + JS.
+- **No anchor revised** — this is a UI-behaviour change (PLAN §7.5 gains `#doc-modal`, `#doc-modal-backdrop`, `#doc-modal-close`, `#doc-modal-content`); the `/document.html` page and its story are unchanged.
+- **A16 honoured** — one story E2E suite (rewritten) + adapted regressions.
+- **A17 honoured** — one atomic `--no-gpg-sign` commit.
diff --git a/.agent/phases/complete/26_document_modal_viewer/01_modal_css_and_html.md b/.agent/phases/complete/26_document_modal_viewer/01_modal_css_and_html.md
new file mode 100644
index 0000000..322634b
--- /dev/null
+++ b/.agent/phases/complete/26_document_modal_viewer/01_modal_css_and_html.md
@@ -0,0 +1,62 @@
+# Task 01 — Modal CSS + HTML skeleton
+
+**Phase:** `26_document_modal_viewer` · **Source:** `TODO.md:4 — "New documents should open in an almost-fullscreen modal, not in a new page"`
+**Story:** `.agent/user_stories/document-modal.md`
+
+## Objective
+Add the modal markup to `index.html` and the CSS that styles an almost-fullscreen overlay (backdrop + panel + close button + scrollable content) using the Phase-08 tokens.
+
+## Work
+1. `frontend/index.html` — insert the modal skeleton just before the closing `` (after the existing script tags, or before them — order doesn't matter for a static skeleton). The skeleton:
+   ```html
+   
+   ```
+   The `hidden` attribute keeps the modal off until JS opens it. The `#doc-modal-open` "Full page" link points at the same `/document.html?source=…&path=…&modal=…` URL the modal will build so a user can still open the dedicated page if JS is off.
+2. `frontend/assets/styles.css` — add a `--doc-modal-*` block (Phase-08 tokens). Styling contract:
+   - `.doc-modal` — `position: fixed; inset: 0; z-index: 1000;` (above the shared header and every page layer, below the phase-25 background which is `z-index: -1`); the panel is flex, column; the backdrop + panel fill the viewport.
+   - `.doc-modal-backdrop` — `position: fixed; inset: 0; background: rgba(10,14,23,0.82);` backdrop blur is **not** used (phase-08 no-blur perf anchor); `opacity` transition 120ms.
+   - `.doc-modal-panel` — `display: flex; flex-direction: column; width: min(1100px, 96vw); height: 92vh; margin: auto; background: var(--surface, #121a2e); border: 1px solid var(--line, #232b52); border-radius: 12px; box-shadow: 0 24px 80px rgba(0,0,0,.55);` — "almost-fullscreen" = 96vw × 92vh, centered.
+   - `.doc-modal-header` — sticky top, same height/spacing as the doc header (64px / 58px pins from phase 12); title uses `--ink`; close button ≥44px target, focus-visible ring.
+   - `.doc-modal-content` — `flex: 1; overflow: auto;` (vertical scroll inside the panel, not the viewport); padding; the md content reuses `.doc-md` (≤46rem centered column) — the modal just provides the scroll container. For wide raw formats the `.doc-raw` pre already has `overflow-x: auto`.
+   - `.doc-modal-meta` — reuses the `.doc-meta` styling already defined for the viewer page (source/format/path/indexed/chunks badges); keep it compact (single row, wrap).
+   - `.doc-modal-close` — icon-only button, `aria-label` kept, `:focus-visible` 3px ring.
+   - Transitions respect `prefers-reduced-motion: reduce` (no opacity/transform animation, or `animation: none` under the reduced-motion media query — same pattern as the phase-25 background layers).
+   - `.doc-modal[hidden]` — `display: none` (the `hidden` IDL attribute default already hides it; add the rule to be explicit and testable).
+   - Ensure the modal panel does not add horizontal width at 360px (no `box-sizing` surprises; the panel is `96vw` ≤ viewport).
+3. Verify the new CSS classes do not collide with any existing selector in `styles.css` (grep for `.doc-modal`, `.doc-modal-`).
+
+## ASSUMPTIONS
+- The modal panel is `96vw × 92vh` ("almost-fullscreen"). If the owner wants a different fraction, that's a follow-up.
+- The "Full page" link is admin-agnostic (it just opens `/document.html`); it is shown for everyone since the viewer is public.
+- The modal uses the existing `.doc-meta` badge classes already defined for the viewer page (no duplicate styling).
+
+## Testing & Quality
+- No unit/integration test for static CSS/HTML.
+- Coverage: frontend-only; the >90% `app/` gate is unaffected.
+
+## Completion Criteria
+- [ ] `index.html` contains the `.doc-modal` skeleton with the documented ids (`#doc-modal`, `#doc-modal-backdrop`, `#doc-modal-panel`, `#doc-modal-close`, `#doc-modal-title`, `#doc-modal-meta`, `#doc-modal-content`, `#doc-modal-open`).
+- [ ] The modal CSS block is present, uses Phase-08 tokens, has no `filter: blur`/`backdrop-filter`, and the panel is `96vw × 92vh` centered.
+- [ ] No selector collision (grep clean).
+- [ ] `prefers-reduced-motion` stills any modal transition.
diff --git a/.agent/phases/complete/26_document_modal_viewer/02_app_js_modal_intercept.md b/.agent/phases/complete/26_document_modal_viewer/02_app_js_modal_intercept.md
new file mode 100644
index 0000000..4cef0ad
--- /dev/null
+++ b/.agent/phases/complete/26_document_modal_viewer/02_app_js_modal_intercept.md
@@ -0,0 +1,40 @@
+# Task 02 — Intercept document links → modal
+
+**Phase:** `26_document_modal_viewer` · **Source:** `TODO.md:4 — "New documents should open in an almost-fullscreen modal, not in a new page"`
+**Story:** `.agent/user_stories/document-modal.md`
+
+## Objective
+Intercept document links on the chat page (`app.js` source chips) and the Sources page (`sources.js` table links): instead of navigating to `/document.html` in a new tab, fetch the document via `GET /api/documents/content` and render it inside the modal from task 01.
+
+## Work
+1. `frontend/assets/document.js` — extract the rendering logic into a reusable, importable function so both the standalone page (task 03) and the modal share the exact same renderer. Specifically:
+   - Export `renderDocument(doc, { containerEl, metaEl, titleEl })` that populates a title element, a meta element (the `.doc-meta` badges), and a content element (`.doc-md` for markdown via `renderMarkdown`, `
` otherwise). Keep the escape-first XSS-safety contract (`innerHTML` only through `renderMarkdown`; `textContent` for raw + badges).
+   - The standalone `document.html` page keeps its own `load()` that calls `renderDocument` with its page elements (task 03 wires this).
+2. `frontend/assets/app.js` — add a `openDocumentModal(source, path)` helper:
+   - Build the modal URL: `/api/documents/content?source=…&path=…` (same encoding the chips already use).
+   - Show the modal: set `#doc-modal.hidden = false`, set the loading state, move focus into `#doc-modal-content` (a11y — the panel is `tabindex="-1"`).
+   - `fetch(contentUrl)` → on `!r.ok` render a short "document not found" line in the content area; on success call `renderDocument` with `#doc-modal-title`, `#doc-modal-meta`, `#doc-modal-content`.
+   - The "Full page" link (`#doc-modal-open`) is set to the `/document.html?source=…&path=…` URL on open.
+   - Keep the existing `documentUrl()` builder for the "Full page" link (unchanged output).
+   - Add modal close behaviour: `#doc-modal-close` click → `closeDocumentModal()`; backdrop click → close; `Escape` key → close; closing restores focus to the link that opened the modal (best-effort — store the triggering element).
+3. `frontend/assets/app.js` — wire the source chips: replace `chip.target = "_blank"` navigation with `chip.addEventListener("click", e => { e.preventDefault(); e.stopPropagation(); openDocumentModal(s.source, s.path, chip); })`. Keep the `title`/aria-label truncation logic the chips already have. The chip keeps its `href` too (no-JS fallback would navigate to `/document.html`).
+4. `frontend/assets/sources.js` — wire the table links the same way: the `.doc-link` click is intercepted, `preventDefault`, and `openDocumentModal(d.source, d.path, link)` is called. Since `openDocumentModal` lives in `app.js` (the chat page module) and `sources.js` is a separate module, **export** `openDocumentModal` from `app.js` and import it in `sources.js` — but `app.js` is loaded as a module on the chat page only. To avoid a second module instance, move the shared modal logic into a small new module `frontend/assets/document-modal.js` (task 02 step 1 refined below) and have both `app.js` and `sources.js` import it.
+   - **Refined split:** create `frontend/assets/document-modal.js` exporting `openDocumentModal(source, path, triggerEl)` and `closeDocumentModal()`. This module owns the modal DOM wiring (close on ESC / backdrop / button, focus management) and the `fetch` + `renderDocument` call. `app.js` and `sources.js` just call `openDocumentModal(...)` from their click handlers. This is the cleanest single-implementation approach (mirrors how `header.js` is the single owner of the shared header).
+   - `document.js` (standalone page) also imports `renderDocument` from itself (or a shared `document-render.js`) — keep the standalone page self-contained; it doesn't need the modal module.
+
+## ASSUMPTIONS
+- The modal module (`document-modal.js`) is a classic or module script loaded on both `index.html` and `sources.html`. It's a module (imports `renderDocument` from `document.js`), so both pages must load it via `` alongside the existing `app.js` module script). `index.html` already loads `markdown.js` as a classic script (needed by `renderDocument`).
+4. `frontend/sources.html` — load `document-modal.js` as a module (it needs `document.js`'s `renderDocument`, so both `document.js` and `document-modal.js` must be module scripts; `markdown.js` classic script stays). The Sources page currently loads `sources.js` as a module; add the modal module script next to it.
+5. Verify the no-CDN integration test (`tests/integration/test_api.py::test_index_html_served_locally`) still passes — the new module scripts are same-origin `
+
+  
+  
 
 
diff --git a/frontend/sources.html b/frontend/sources.html
index fb688f3..302264f 100644
--- a/frontend/sources.html
+++ b/frontend/sources.html
@@ -121,7 +121,44 @@
 
   
+       before the page script body calls initSharedHeader() at boot.
+       Phase 26: markdown.js (the classic global renderMarkdown) loads
+       BEFORE the module script — the document modal renders md
+       documents through it on this page too. -->
+  
   
+
+  
+  
 
 
diff --git a/tests/e2e/test_chat_persistence.py b/tests/e2e/test_chat_persistence.py
index 26f1f9f..6b478a9 100644
--- a/tests/e2e/test_chat_persistence.py
+++ b/tests/e2e/test_chat_persistence.py
@@ -158,7 +158,7 @@ def test_conversation_survives_reload(
     chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md")
     expect(chip).to_have_count(1)
     expect(chip.first).to_have_attribute("href", CHIP_HREF)
-    expect(chip.first).to_have_attribute("target", "_blank")
+    expect(chip.first).not_to_have_attribute("target")  # phase 26: modal, not a new tab
 
     # The restore is read-only: storage still holds the same two messages.
     assert [m["who"] for m in _stored_parsed(page)["messages"]] == ["user", "brain"]
diff --git a/tests/e2e/test_chat_rag.py b/tests/e2e/test_chat_rag.py
index 9cace14..50ecb14 100644
--- a/tests/e2e/test_chat_rag.py
+++ b/tests/e2e/test_chat_rag.py
@@ -98,16 +98,16 @@ def test_on_topic_question_streams_grounded_answer(
 
     # Grounded: a kubernetes.md source chip renders under the bubble
     # (top-N docs can add more chips; the question's doc must be among them).
-    # Phase 10: chips open the document viewer in a new tab (encoded URL);
-    # phase 13 appends back=/ so the viewer's back button returns to chat.
+    # Phase 26: the chip opens the document in the SAME-PAGE modal — no new
+    # tab; the encoded href stays as the no-JS / context-menu escape hatch
+    # (phase 13's back=/ lets the viewer's back button return to chat).
     chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md")
     expect(chip).to_have_count(1)
     expect(chip.first).to_contain_text("kubernetes.md")
     expect(chip.first).to_have_attribute(
         "href", "/document.html?source=docs&path=homelab%2Fkubernetes.md&back=%2F"
     )
-    expect(chip.first).to_have_attribute("target", "_blank")
-    expect(chip.first).to_have_attribute("rel", "noopener")
+    expect(chip.first).not_to_have_attribute("target")  # phase 26: modal, not a new tab
 
     # Button recovers: enabled + "Send" (never stale).
     expect(page.locator("#send-btn")).to_be_enabled()
diff --git a/tests/e2e/test_document_back_navigation.py b/tests/e2e/test_document_back_navigation.py
index 1e0762c..489cd4d 100644
--- a/tests/e2e/test_document_back_navigation.py
+++ b/tests/e2e/test_document_back_navigation.py
@@ -6,21 +6,26 @@ Run in isolation (DB must be up: ``podman compose up -d db``):
 
     uv run pytest tests/e2e/test_document_back_navigation.py -v --no-cov
 
-Both entry points (chat source chips, Sources table links) open the viewer
-in a NEW tab, where there is no browser history — so the return target is
-carried in the viewer URL: chat chips append ``&back=%2F`` (resolves to
-"Chat"), Sources links omit the param (the viewer's default
-``/sources.html`` applies → "Sources"). The viewer only honors
-same-origin relative ``back`` values; everything else falls back to
-``/sources.html``.
+The viewer can be reached directly (no browser history to go back to),
+so the return target is carried in the viewer URL: chat chips append
+``&back=%2F`` (resolves to "Chat"), Sources links omit the param (the
+viewer's default ``/sources.html`` applies → "Sources"). The viewer only
+honors same-origin relative ``back`` values; everything else falls back
+to ``/sources.html``.
+
+Phase 26 adaptation: the chip/row-link LEFT click now opens the
+document in the same-page modal — no new tab is spawned. The encoded
+viewer URL survives as each link's ``href`` (the no-JS / context-menu
+"open in new tab" escape hatch), so the back contract is asserted on
+that exact href and verified by navigating to it directly.
 
 Test → story mapping (Playwright Mapping Rule):
-1. ``test_back_from_chat_returns_to_chat`` — question → source chip →
-   new tab with ``&back=%2F`` → back link href ``/`` labeled "Chat" →
-   click → the chat page.
-2. ``test_back_from_sources_returns_to_sources`` — Sources table link →
-   new tab without a ``back`` param → back link href ``/sources.html``
-   labeled "Sources" → click → the Sources page.
+1. ``test_back_from_chat_returns_to_chat`` — question → source chip href
+   (carries ``&back=%2F``) → viewer back link href ``/`` labeled "Chat"
+   → click → the chat page.
+2. ``test_back_from_sources_returns_to_sources`` — Sources table link
+   href (no ``back`` param) → back link href ``/sources.html`` labeled
+   "Sources" → click → the Sources page.
 3. ``test_malicious_back_param_is_rejected`` — absolute,
    protocol-relative, and ``javascript:`` ``back`` values all fall back
    to ``/sources.html`` (labeled "Sources", navigable).
@@ -106,30 +111,33 @@ def test_back_from_chat_returns_to_chat(
 
     chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md")
     expect(chip).to_have_count(1, timeout=30_000)
-    # Chat chips carry back=/ (encoded %2F) so the viewer knows where home is.
+    # Chat chips carry back=/ (encoded %2F) so the viewer knows where
+    # home is. Phase 26: the left click opens the same-page modal (no
+    # target=_blank); this href is what the no-JS / context-menu "open
+    # in a new tab" path reaches, so the back contract rides on it.
     expect(chip.first).to_have_attribute(
         "href", f"/document.html?source={DOC_SOURCE}&path={DOC_PATH}&back=%2F"
     )
+    expect(chip.first).not_to_have_attribute("target")  # phase 26: modal, not a new tab
 
-    with page.expect_popup() as popup_info:
-        chip.first.click()
-    viewer = popup_info.value
-    expect(viewer).to_have_url(
+    # The exact href asserted above (the no-JS / new-tab escape hatch).
+    page.goto(f"{app_url}/document.html?source={DOC_SOURCE}&path={DOC_PATH}&back=%2F")
+    expect(page).to_have_url(
         re.compile(
             re.escape(f"{app_url}/document.html?source={DOC_SOURCE}&path={DOC_PATH}&back=%2F")
         )
     )
     # The cited document actually rendered (this is the viewer, not an error).
-    expect(viewer.locator("#doc-title")).to_have_text(DOC_TITLE)
+    expect(page.locator("#doc-title")).to_have_text(DOC_TITLE)
     # Back link resolved to the chat page, labeled "Chat".
-    back = viewer.locator("#doc-back")
+    back = page.locator("#doc-back")
     expect(back).to_have_attribute("href", "/")
     expect(back).to_have_text("Chat")
 
     # Click: deterministic anchor navigation back to the chat page.
     back.click()
-    expect(viewer).to_have_url(f"{app_url}/")
-    expect(viewer.locator("#composer")).to_be_visible()
+    expect(page).to_have_url(f"{app_url}/")
+    expect(page.locator("#composer")).to_be_visible()
 
 
 # ---------------------------------------------------------------------------
@@ -148,24 +156,25 @@ def test_back_from_sources_returns_to_sources(
     link = row.locator("td:nth-child(2) a.doc-link")
     expect(link).to_have_count(1)
     # Sources links carry NO back param — the viewer's default target
-    # (/sources.html) applies.
+    # (/sources.html) applies. Phase 26: left click opens the modal;
+    # the href (no back param) is the no-JS / new-tab escape hatch.
     expect(link).to_have_attribute(
         "href", f"/document.html?source={DOC_SOURCE}&path={DOC_PATH}"
     )
+    expect(link).not_to_have_attribute("target")  # phase 26: modal, not a new tab
 
-    with page.expect_popup() as popup_info:
-        link.click()
-    viewer = popup_info.value
-    assert "back=" not in viewer.url, f"unexpected back param: {viewer.url}"
-    expect(viewer.locator("#doc-title")).to_have_text(DOC_TITLE)
+    # The exact href asserted above — no back param in the URL.
+    page.goto(f"{app_url}/document.html?source={DOC_SOURCE}&path={DOC_PATH}")
+    assert "back=" not in page.url, f"unexpected back param: {page.url}"
+    expect(page.locator("#doc-title")).to_have_text(DOC_TITLE)
     # Back link kept the default target, labeled "Sources".
-    back = viewer.locator("#doc-back")
+    back = page.locator("#doc-back")
     expect(back).to_have_attribute("href", "/sources.html")
     expect(back).to_have_text("Sources")
 
     back.click()
-    expect(viewer).to_have_url(f"{app_url}/sources.html")
-    expect(viewer.locator("#docs-table")).to_be_visible()
+    expect(page).to_have_url(f"{app_url}/sources.html")
+    expect(page.locator("#docs-table")).to_be_visible()
 
 
 # ---------------------------------------------------------------------------
diff --git a/tests/e2e/test_document_viewer.py b/tests/e2e/test_document_viewer.py
index 5ca2f62..c08e481 100644
--- a/tests/e2e/test_document_viewer.py
+++ b/tests/e2e/test_document_viewer.py
@@ -1,25 +1,32 @@
-"""Phase 10 E2E (Playwright): the clickable document viewer.
+"""Phase 26 E2E (Playwright): documents open in the almost-fullscreen
+modal — not in a new page.
 
-Story: ``.agent/user_stories/document-viewer.md``
+Story: ``.agent/user_stories/document-modal.md``
 Run in isolation (DB must be up: ``podman compose up -d db``):
 
     uv run pytest tests/e2e/test_document_viewer.py -v --no-cov
 
 Seeding reuses the real importer against ``tests/fixtures/docs/`` with the
-deterministic mock embeddings (same pattern as the earlier story suites).
+deterministic mock embeddings (same harness as the phase-10 suite — only
+the assertions changed: chips/row links now open the SAME-PAGE modal,
+no ``expect_popup``).
 
 Test → story mapping (Playwright Mapping Rule):
-1. ``test_source_chip_opens_document``       — chip → NEW TAB → viewer with
-   title + known content string + format badge.
-2. ``test_sources_row_links_to_viewer``      — Sources path link (yaml
-   fixture) → viewer with raw content in a ``pre``.
-3. ``test_markdown_renders_and_stays_xss_safe`` — md fixture containing
-   ```` renders as visible escaped text (no
-   execution).
-4. ``test_missing_doc_shows_not_found``      — unknown doc → not-found
-   state + Sources link; no console crash.
-5. ``test_viewer_theme_and_no_cdn``          — dark theme + every
-   ``script[src]`` / ``link[href]`` local or ``data:`` + a11y frame.
+1. ``test_source_chip_opens_modal`` — chat chip → modal opens in-page
+   (NO new tab, URL unchanged), title + ``.doc-md`` content + meta row.
+2. ``test_sources_row_opens_modal`` — Sources path link → modal, yaml in
+   ````, mono font, URL unchanged.
+3. ``test_modal_closes_on_button_escape_and_backdrop`` — close via
+   ``#doc-modal-close``, via backdrop click, via ``Escape``.
+4. ``test_modal_focus_and_a11y`` — ``role="dialog"`` + ``aria-modal``,
+   focus inside the panel on open, close button has an ``aria-label``.
+5. ``test_modal_xss_safe`` — hostile md document opened through the modal
+   renders as escaped text; no dialog fires.
+6. ``test_standalone_page_still_works`` — the dedicated ``/document.html``
+   page keeps its phase-10 contract (title/content/badges, not-found,
+   dark theme, no-CDN, a11y frame, ≤736px md column).
+7. ``test_modal_theme_and_no_cdn`` — dark page background, the panel on
+   the Phase-08 surface colour, every asset same-origin or ``data:``.
 """
 from __future__ import annotations
 
@@ -83,60 +90,84 @@ def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
     return _run_in_thread(_import_fixtures(mock_port))
 
 
+def _ask_for_chip(page: Page, app_url: str) -> Any:
+    """Drive one chat turn and return the kubernetes.md source chip."""
+    page.goto(app_url)
+    page.fill("#message-input", QUESTION)
+    page.click("#send-btn")
+    chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md")
+    expect(chip).to_have_count(1, timeout=30_000)
+    return chip
+
+
+def _assert_closed(page: Page) -> None:
+    """The modal is fully closed: the hidden attribute is back and the
+    overlay is gone from view."""
+    expect(page.locator("#doc-modal")).to_have_attribute("hidden", "")
+    expect(page.locator(".doc-modal")).not_to_be_visible()
+
+
 # ---------------------------------------------------------------------------
-# 1. Chat source chip → new tab → full document
+# 1. Chat source chip → SAME-PAGE modal (no new tab)
 # ---------------------------------------------------------------------------
 
 
-def test_source_chip_opens_document(
+def test_source_chip_opens_modal(
     page: Page, app_url: str, mock_llm: int, db_ready: None
 ) -> None:
     _reset_db(mock_llm, seed=True)
     page.set_default_timeout(30_000)
-    page.goto(app_url)
-
-    page.fill("#message-input", QUESTION)
-    page.click("#send-btn")
-
-    chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md")
-    expect(chip).to_have_count(1, timeout=30_000)
-    # New-tab contract: same-origin viewer URL, all query values encoded
-    # (the path's slashes come out as %2F — exactly why encoding matters),
-    # plus back=/ (phase 13) so the viewer's back button returns to chat.
+    chip = _ask_for_chip(page, app_url)
+    # The encoded viewer URL stays as the no-JS / context-menu escape
+    # hatch — but phase 26 removed target=_blank: the left click is
+    # intercepted and opens the modal in place.
     expect(chip.first).to_have_attribute(
         "href", "/document.html?source=docs&path=homelab%2Fkubernetes.md&back=%2F"
     )
-    expect(chip.first).to_have_attribute("target", "_blank")
-    expect(chip.first).to_have_attribute("rel", "noopener")
+    expect(chip.first).not_to_have_attribute("target")
 
-    with page.expect_popup() as popup_info:
-        chip.first.click()
-    viewer = popup_info.value
-    expect(viewer).to_have_url(
-        re.compile(
-            re.escape(
-                f"{app_url}/document.html?source=docs&path=homelab%2Fkubernetes.md&back=%2F"
-            )
-        )
+    before = len(page.context.pages)
+    chip.first.click()
+    # No new tab: the click must not have spawned a page.
+    assert len(page.context.pages) == before, "clicking a chip must not open a new tab"
+
+    # The almost-fullscreen modal becomes visible (hidden attribute gone).
+    expect(page.locator("#doc-modal")).not_to_have_attribute("hidden")
+    expect(page.locator(".doc-modal")).to_be_visible()
+    # "Almost-fullscreen": the panel is min(1100px, 96vw) × 92vh, centered
+    # (at a 1280px viewport the 1100px cap wins over 96vw = 1228.8px).
+    box = page.locator("#doc-modal-panel").bounding_box()
+    assert box is not None, "modal panel not rendered"
+    expected_w = min(1100, 0.96 * 1280)
+    expected_h = 0.92 * 800
+    assert abs(box["width"] - expected_w) < 2, f"panel width {box['width']} (want ~{expected_w})"
+    assert abs(box["height"] - expected_h) < 2, f"panel height {box['height']} (want ~{expected_h})"
+
+    # Same content the /document.html page renders: title, markdown in
+    # the centered .doc-md column (the modal's content target is
+    # #doc-modal-content — the modal variant of the page's #doc-content).
+    expect(page.locator("#doc-modal-title")).to_have_text("Kubernetes Homelab Cluster")
+    expect(page.locator("#doc-modal-content .doc-md")).to_have_count(1)
+    expect(page.locator("#doc-modal-content")).to_contain_text("Talos Linux on three nodes")
+    # Meta row mirrors the viewer: source · format · mono path · indexed · chunks.
+    expect(page.locator("#doc-modal-meta .doc-source-badge")).to_have_text("docs")
+    expect(page.locator("#doc-modal-meta .format-badge")).to_have_text("md")
+    expect(page.locator("#doc-modal-meta .doc-path")).to_have_text("homelab/kubernetes.md")
+    expect(page.locator("#doc-modal-meta .doc-indexed")).to_contain_text("Indexed")
+    assert re.fullmatch(
+        r"\d+ chunks?", page.locator("#doc-modal-meta .doc-chunks").inner_text()
     )
-    expect(viewer.locator("#doc-title")).to_have_text("Kubernetes Homelab Cluster")
-    # Meta row: source badge · format badge · mono path · indexed · chunks.
-    expect(viewer.locator("#doc-meta .doc-source-badge")).to_have_text("docs")
-    expect(viewer.locator("#doc-meta .format-badge")).to_have_text("md")
-    expect(viewer.locator("#doc-meta .doc-path")).to_have_text("homelab/kubernetes.md")
-    expect(viewer.locator("#doc-meta .doc-indexed")).to_contain_text("Indexed")
-    assert re.fullmatch(r"\d+ chunks?", viewer.locator("#doc-meta .doc-chunks").inner_text())
-    # Full document, rendered markdown in the centered column (not a pre).
-    expect(viewer.locator("#doc-content .doc-md")).to_have_count(1)
-    expect(viewer.locator("#doc-content")).to_contain_text("Talos Linux on three nodes")
+
+    # Still the chat page: no navigation happened.
+    assert page.url == app_url + "/", f"navigated away: {page.url}"
 
 
 # ---------------------------------------------------------------------------
-# 2. Sources table path link → viewer (yaml → raw pre)
+# 2. Sources table path link → same-page modal (yaml → raw pre)
 # ---------------------------------------------------------------------------
 
 
-def test_sources_row_links_to_viewer(
+def test_sources_row_opens_modal(
     page: Page, app_url: str, mock_llm: int, db_ready: None
 ) -> None:
     _reset_db(mock_llm, seed=True)
@@ -146,34 +177,119 @@ def test_sources_row_links_to_viewer(
     expect(row).to_have_count(1)
     link = row.locator("td:nth-child(2) a.doc-link")
     expect(link).to_have_count(1)
-    # Encoded URL: the slashes in the path value come out as %2F.
+    # Encoded URL kept as the escape hatch (slashes come out as %2F);
+    # no target=_blank any more.
     expect(link).to_have_attribute(
         "href",
         "/document.html?source=docs&path=homelab%2Fcontainer_gitlab%2Fgitlab-compose.yaml",
     )
-    expect(link).to_have_attribute("target", "_blank")
-    expect(link).to_have_attribute("rel", "noopener")
+    expect(link).not_to_have_attribute("target")
     expect(link).to_have_attribute("title", "homelab/container_gitlab/gitlab-compose.yaml")
 
-    with page.expect_popup() as popup_info:
-        link.click()
-    viewer = popup_info.value
-    expect(viewer.locator("#doc-title")).to_have_text("gitlab-compose")
-    expect(viewer.locator("#doc-meta .format-badge")).to_have_text("yaml")
+    before = len(page.context.pages)
+    link.click()
+    assert len(page.context.pages) == before, "clicking a row link must not open a new tab"
+
+    expect(page.locator(".doc-modal")).to_be_visible()
+    expect(page.locator("#doc-modal-title")).to_have_text("gitlab-compose")
+    expect(page.locator("#doc-modal-meta .format-badge")).to_have_text("yaml")
     # Non-markdown formats render as escaped monospace text in a pre.
-    pre = viewer.locator("#doc-content pre.doc-raw")
+    pre = page.locator("#doc-modal-content pre.doc-raw")
     expect(pre).to_have_count(1)
     expect(pre).to_contain_text("gitlab/gitlab-ce:17.2.1-ce.0")
     font = pre.evaluate("el => getComputedStyle(el).fontFamily")
     assert "mono" in font
 
+    # Still on the Sources page: no navigation happened.
+    assert page.url == app_url + "/sources.html", f"navigated away: {page.url}"
+
 
 # ---------------------------------------------------------------------------
-# 3. Markdown renders through the shared renderer and stays XSS-safe
+# 3. Close on button, backdrop, and Escape
 # ---------------------------------------------------------------------------
 
 
-def test_markdown_renders_and_stays_xss_safe(
+def test_modal_closes_on_button_escape_and_backdrop(
+    page: Page, app_url: str, mock_llm: int, db_ready: None
+) -> None:
+    _reset_db(mock_llm, seed=True)
+    page.set_default_timeout(30_000)
+    chip = _ask_for_chip(page, app_url)
+
+    def open_and_loaded() -> None:
+        chip.first.click()
+        expect(page.locator("#doc-modal-title")).to_have_text("Kubernetes Homelab Cluster")
+
+    # 1) The close button.
+    open_and_loaded()
+    page.click("#doc-modal-close")
+    _assert_closed(page)
+
+    # 2) The backdrop — a point outside the centered 96vw × 92vh panel
+    #    (panel starts at 4vh from the top / 2vw from the edge).
+    open_and_loaded()
+    page.locator("#doc-modal-backdrop").click(position={"x": 5, "y": 5})
+    _assert_closed(page)
+
+    # 3) Escape — the capture is document-level, so it works from any
+    #    focus position inside (or outside) the panel.
+    open_and_loaded()
+    page.keyboard.press("Escape")
+    _assert_closed(page)
+
+    # After closing, the page behind is untouched: chat is still there.
+    assert page.url == app_url + "/"
+    expect(page.locator("#composer")).to_be_visible()
+
+
+# ---------------------------------------------------------------------------
+# 4. Focus management + dialog a11y frame
+# ---------------------------------------------------------------------------
+
+
+def test_modal_focus_and_a11y(
+    page: Page, app_url: str, mock_llm: int, db_ready: None
+) -> None:
+    _reset_db(mock_llm, seed=True)
+    page.set_default_timeout(30_000)
+    chip = _ask_for_chip(page, app_url)
+
+    chip.first.click()
+    expect(page.locator("#doc-modal-title")).to_have_text("Kubernetes Homelab Cluster")
+
+    # The panel is a proper modal dialog, labelled by its title.
+    panel = page.locator("#doc-modal-panel")
+    expect(panel).to_have_attribute("role", "dialog")
+    expect(panel).to_have_attribute("aria-modal", "true")
+    expect(panel).to_have_attribute("aria-labelledby", "doc-modal-title")
+
+    # On open, focus moves into the dialog's content target.
+    focus_id = page.evaluate("() => document.activeElement && document.activeElement.id")
+    assert focus_id == "doc-modal-content", f"focus {focus_id!r} did not move into the modal"
+
+    # The close control carries an accessible name (icon-only button).
+    expect(page.locator("#doc-modal-close")).to_have_attribute("aria-label", "Close document")
+
+    # The "Full page" escape hatch is rebuilt to the same encoded viewer
+    # URL (no back param — the dedicated page's own default applies).
+    expect(page.locator("#doc-modal-open")).to_be_visible()
+    expect(page.locator("#doc-modal-open")).to_have_attribute(
+        "href", "/document.html?source=docs&path=homelab%2Fkubernetes.md"
+    )
+
+    # Closing returns focus to the triggering control.
+    page.keyboard.press("Escape")
+    _assert_closed(page)
+    focus_id = page.evaluate("() => document.activeElement && document.activeElement.className")
+    assert "source-chip" in (focus_id or ""), f"focus {focus_id!r} did not return to the chip"
+
+
+# ---------------------------------------------------------------------------
+# 5. Modal rendering stays XSS-safe (hostile md, opened via the modal)
+# ---------------------------------------------------------------------------
+
+
+def test_modal_xss_safe(
     page: Page, app_url: str, mock_llm: int, db_ready: None
 ) -> None:
     _reset_db(mock_llm, seed=True)
@@ -200,28 +316,46 @@ def test_markdown_renders_and_stays_xss_safe(
         d.dismiss()
 
     page.on("dialog", _catch_dialog)
-    page.goto(f"{app_url}/document.html?source=docs&path=notes%2Fxss-fixture.md")
 
-    expect(page.locator("#doc-title")).to_have_text("Xss Fixture")
+    # The Sources table lists every indexed document — the admin entry
+    # point into the modal for a doc the chat never cited.
+    login(page, app_url)
+    row = page.locator("#docs-tbody tr", has_text="xss-fixture.md")
+    expect(row).to_have_count(1)
+    row.locator("td:nth-child(2) a.doc-link").click()
+
+    expect(page.locator(".doc-modal")).to_be_visible()
+    expect(page.locator("#doc-modal-title")).to_have_text("Xss Fixture")
     # The tag shows up as VISIBLE, ESCAPED text — rendered, never executed.
-    expect(page.locator("#doc-content")).to_contain_text("")
-    expect(page.locator("#doc-content")).to_contain_text("XSS-FIXTURE-MARKER")
-    assert page.locator("#doc-content script").count() == 0, "hostile script became live HTML"
+    expect(page.locator("#doc-modal-content")).to_contain_text("")
+    expect(page.locator("#doc-modal-content")).to_contain_text("XSS-FIXTURE-MARKER")
+    assert page.locator("#doc-modal-content script").count() == 0, "hostile script became live HTML"
     assert dialogs == [], f"dialog fired — script executed: {dialogs}"
 
 
 # ---------------------------------------------------------------------------
-# 4. Missing document → designed not-found state, no console crash
+# 6. The dedicated /document.html page keeps its phase-10 contract
 # ---------------------------------------------------------------------------
 
 
-def test_missing_doc_shows_not_found(
+def test_standalone_page_still_works(
     page: Page, app_url: str, mock_llm: int, db_ready: None
 ) -> None:
     _reset_db(mock_llm, seed=True)
     errors: list[str] = []
     page.on("pageerror", lambda e: errors.append(str(e)))
 
+    # Direct link renders exactly as before (phase 10): title, meta row,
+    # markdown in the centered column.
+    page.goto(f"{app_url}/document.html?source=docs&path=homelab%2Fkubernetes.md")
+    expect(page.locator("#doc-title")).to_have_text("Kubernetes Homelab Cluster")
+    expect(page.locator("#doc-meta .doc-source-badge")).to_have_text("docs")
+    expect(page.locator("#doc-meta .format-badge")).to_have_text("md")
+    expect(page.locator("#doc-content .doc-md")).to_have_count(1)
+    expect(page.locator("#doc-content")).to_contain_text("Talos Linux on three nodes")
+
+    # Not-found state: an unknown pair AND missing params — no console
+    # crash, the designed card with the Sources link.
     page.goto(f"{app_url}/document.html?source=docs&path=definitely/not/here.md")
     expect(page.locator("#doc-title")).to_have_text("Document not found")
     card = page.locator("#doc-not-found")
@@ -229,29 +363,15 @@ def test_missing_doc_shows_not_found(
     expect(card).to_contain_text("Document not found")
     expect(card.locator("a.doc-open-sources")).to_have_attribute("href", "/sources.html")
     expect(page.locator("#doc-content")).to_be_empty()
-
-    # Missing params → the same designed state (no fetch, no crash).
     page.goto(f"{app_url}/document.html")
     expect(page.locator("#doc-not-found")).to_be_visible()
 
-    assert errors == [], f"console crashes: {errors}"
-
-
-# ---------------------------------------------------------------------------
-# 5. Dark theme + all assets local + a11y frame
-# ---------------------------------------------------------------------------
-
-
-def test_viewer_theme_and_no_cdn(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None:
-    _reset_db(mock_llm, seed=True)
+    # Dark theme + all assets local + a11y frame + capped md column.
     page.goto(f"{app_url}/document.html?source=docs&path=homelab%2Fkubernetes.md")
     expect(page.locator("#doc-content .doc-md")).not_to_be_empty()
-
-    # Dark theme inherited from phase 08 (same sampling as that story).
     bg = page.evaluate("() => getComputedStyle(document.documentElement).backgroundColor")
     assert bg == "rgb(10, 14, 23)"
 
-    # No-CDN: every script/link reference is same-origin or a data: URI.
     refs = page.evaluate(
         """() => [...document.querySelectorAll("script[src], link[href]")]
             .map((el) => el.src || el.href)"""
@@ -260,8 +380,6 @@ def test_viewer_theme_and_no_cdn(page: Page, app_url: str, mock_llm: int, db_rea
     for ref in refs:
         assert ref.startswith(app_url) or ref.startswith("data:"), f"non-local: {ref}"
 
-    # A11y frame: landmarks, skip link, aria-live around the load→content
-    # swap, and focus moved to main on load.
     expect(page.locator("header.doc-header")).to_have_count(1)
     expect(page.locator("main#main")).to_have_count(1)
     expect(page.locator("footer.app-footer")).to_have_count(1)
@@ -272,3 +390,40 @@ def test_viewer_theme_and_no_cdn(page: Page, app_url: str, mock_llm: int, db_rea
     # Markdown column centered and capped at 46rem (736px at 16px root).
     box = page.locator("#doc-content .doc-md").bounding_box()
     assert box is not None and box["width"] <= 736 + 1
+
+    assert errors == [], f"console crashes: {errors}"
+
+
+# ---------------------------------------------------------------------------
+# 7. Modal theme + no CDN on the touched page
+# ---------------------------------------------------------------------------
+
+
+def test_modal_theme_and_no_cdn(
+    page: Page, app_url: str, mock_llm: int, db_ready: None
+) -> None:
+    _reset_db(mock_llm, seed=True)
+    page.set_default_timeout(30_000)
+    chip = _ask_for_chip(page, app_url)
+    chip.first.click()
+    expect(page.locator("#doc-modal-title")).to_have_text("Kubernetes Homelab Cluster")
+    expect(page.locator("#doc-modal-content .doc-md")).not_to_be_empty()
+
+    # Dark theme (phase 08): the page background is untouched, and the
+    # modal panel sits on the Phase-08 surface colour (#121a2e).
+    bg = page.evaluate("() => getComputedStyle(document.documentElement).backgroundColor")
+    assert bg == "rgb(10, 14, 23)"
+    surface = page.evaluate(
+        "() => getComputedStyle(document.querySelector('.doc-modal-panel')).backgroundColor"
+    )
+    assert surface == "rgb(18, 26, 46)", f"panel not on the Phase-08 surface: {surface}"
+
+    # No-CDN: every script/link reference on the chat page (the touched
+    # page) is same-origin or a data: URI — the modal adds no assets.
+    refs = page.evaluate(
+        """() => [...document.querySelectorAll("script[src], link[href]")]
+            .map((el) => el.src || el.href)"""
+    )
+    assert refs, "expected local asset references on the chat page"
+    for ref in refs:
+        assert ref.startswith(app_url) or ref.startswith("data:"), f"non-local: {ref}"
diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py
index 3c759a3..45e7d7a 100644
--- a/tests/integration/test_api.py
+++ b/tests/integration/test_api.py
@@ -77,6 +77,7 @@ def test_styles_and_js_served(client) -> None:
     assert client.get("/assets/markdown.js").status_code == 200  # phase 10: shared renderer
     assert client.get("/assets/document.js").status_code == 200  # phase 10: viewer page
     assert client.get("/assets/login.js").status_code == 200  # phase 16: login page
+    assert client.get("/assets/document-modal.js").status_code == 200  # phase 26: modal module
 
 
 # Emoji code points banned from UI chrome (phase 08): the pictograph
@@ -112,6 +113,7 @@ def _find_emoji(text: str) -> list[str]:
         "/assets/markdown.js",
         "/assets/document.js",
         "/assets/login.js",  # phase 16
+        "/assets/document-modal.js",  # phase 26: the document modal module
         "/assets/styles.css",
     ],
 )
diff --git a/tests/unit/test_document_viewer.py b/tests/unit/test_document_viewer.py
index 2edce90..4c331a0 100644
--- a/tests/unit/test_document_viewer.py
+++ b/tests/unit/test_document_viewer.py
@@ -13,8 +13,11 @@ Frontend side:
   spaces/slashes) executed under node when available, plus source pins that
   run everywhere;
 * the shared-renderer extraction — ``markdown.js`` holds the renderer,
-  loaded by BOTH pages via a relative ``', html)
+    assert html.index('src="assets/markdown.js"') < html.index('type="module"'), (
+        "sources.html: markdown.js must load before the module script"
+    )
+
+
+def test_modal_close_contract_pins() -> None:
+    """Phase 26: the modal closes on the close button, on backdrop
+    click, and on Escape (captured document-level, so it works from any
+    focus position); focus returns to the triggering control
+    (best-effort); the fetch goes to the stateless content endpoint with
+    the same percent-encoding the page uses, and success renders through
+    the shared renderDocument; the "Full page" link is rebuilt on open."""
+    js = _read(MODAL_JS)
+    assert 'e.key === "Escape"' in js
+    assert 'addEventListener("keydown"' in js
+    assert "backdropEl.addEventListener(\"click\", closeDocumentModal)" in js
+    assert "closeEl.addEventListener(\"click\", closeDocumentModal)" in js
+    assert "triggerEl.focus" in js  # best-effort focus restore
+    assert '"/api/documents/content?source=" + encodeURIComponent(' in js
+    assert '"&path=" + encodeURIComponent(' in js
+    # The shared renderer (not a copy), called with the modal's own
+    # #doc-modal-title / #doc-modal-meta / #doc-modal-content elements.
+    assert "renderDocument(doc, { titleEl, metaEl, contentEl })" in js
+    assert 'openEl.href = fullPageUrl(source, path)' in js
+
+
+@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
+def test_modal_url_builders_encode_like_the_page() -> None:
+    """Behavioral check (node) of the modal's own URL builders: the
+    content fetch and the "Full page" href must come out percent-encoded
+    exactly like the page's builders (slashes/spaces in real paths)."""
+    js = _read(MODAL_JS)
+    content_fn = _extract_function(js, "contentUrl")
+    full_fn = _extract_function(js, "fullPageUrl")
+    out = _run_node(
+        content_fn
+        + full_fn
+        + "\nconsole.log(contentUrl('Homelab', 'notes/my file.yaml'));\n"
+        + "console.log(fullPageUrl('H omelab', 'a/b.md'));"
+    )
+    assert out.splitlines() == [
+        "/api/documents/content?source=Homelab&path=notes%2Fmy%20file.yaml",
+        "/document.html?source=H%20omelab&path=a%2Fb.md",
+    ]
+
+
 def test_viewer_js_rendering_contracts() -> None:
     """document.js: raw formats go in via textContent (never parsed as
     HTML), markdown via the shared renderer, 404 → designed not-found