From 6ec6181c7b5563764cc8f570a28d3ae71c680629 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Sat, 22 Aug 2026 02:08:49 -0400 Subject: [PATCH] =?UTF-8?q?feat(ui):=20clickable=20document=20viewer=20?= =?UTF-8?q?=E2=80=94=20open=20any=20cited=20document=20in=20the=20browser?= =?UTF-8?q?=20from=20chat=20chips=20and=20the=20sources=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 13 + app/api/docs.py | 51 +++- app/schemas.py | 12 + frontend/assets/app.js | 52 +--- frontend/assets/document.js | 116 +++++++++ frontend/assets/markdown.js | 51 ++++ frontend/assets/sources.js | 31 ++- frontend/assets/styles.css | 180 +++++++++++++- frontend/document.html | 58 +++++ frontend/index.html | 1 + tests/e2e/test_chat_rag.py | 7 +- tests/e2e/test_document_viewer.py | 270 +++++++++++++++++++++ tests/integration/test_api.py | 29 ++- tests/integration/test_document_content.py | 137 +++++++++++ tests/unit/test_document_viewer.py | 268 ++++++++++++++++++++ 15 files changed, 1218 insertions(+), 58 deletions(-) create mode 100644 frontend/assets/document.js create mode 100644 frontend/assets/markdown.js create mode 100644 frontend/document.html create mode 100644 tests/e2e/test_document_viewer.py create mode 100644 tests/integration/test_document_content.py create mode 100644 tests/unit/test_document_viewer.py diff --git a/README.md b/README.md index dff92b8..14bfe5f 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,19 @@ uv run uvicorn app.main:app --reload > πŸ“ **After this, day-to-day is just: edit markdown β†’ re-run the import.** > See [Updating the documents](#updating-the-documents) below. +## Using the UI + +- **Chat** (`/`) β€” ask questions; answers stream in with **source chips** + that cite the exact documents used. Clicking a chip opens that document + **in a new tab**. +- **Document viewer** (`/document.html?source=…&path=…`) β€” the full text of + any indexed document, served from the database (no filesystem access): + markdown is rendered, every other format (`yaml`, `json`, `py`, `txt`, …) + is shown as escaped monospace text. Unknown documents get a designed + not-found state with a link back to the index. +- **Sources** (`/sources.html`) β€” the indexed document list; the *Path* + column links each document to the viewer in a new tab. + ## Updating the documents **This is the workflow you'll use most.** The knowledge base is refreshed by diff --git a/app/api/docs.py b/app/api/docs.py index db122c7..74ba818 100644 --- a/app/api/docs.py +++ b/app/api/docs.py @@ -1,17 +1,32 @@ -"""GET /api/docs β€” the indexed document list (feeds the Sources page).""" +"""GET /api/docs β€” the indexed document list (feeds the Sources page). + +GET /api/documents/content β€” one indexed document's full content (feeds the +clickable document viewer, phase 10). DB-only by design: the (source, path) +pair is looked up as a row, so there is no filesystem access and no +path-traversal surface β€” ``../``-style values simply aren't rows (β†’ 404). +""" from __future__ import annotations -from fastapi import APIRouter, Depends +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import func, select from sqlalchemy.orm import Session from app.db import get_db from app.models import Chunk, Document -from app.schemas import DocList, DocSummary +from app.schemas import DocContent, DocList, DocSummary router = APIRouter(tags=["kb"]) +def doc_format(path: str) -> str: + """Lowercased path suffix without its dot (``kubernetes.md`` β†’ ``md``, + ``notes/deep.Markdown`` β†’ ``markdown``); ``text`` when the path has no + suffix β€” the value shown in the viewer's format badge.""" + return Path(path).suffix.lower().lstrip(".") or "text" + + @router.get("/docs", response_model=DocList) def list_documents(db: Session = Depends(get_db)) -> DocList: # noqa: B008 """All indexed documents with per-document chunk counts. @@ -45,3 +60,33 @@ def list_documents(db: Session = Depends(get_db)) -> DocList: # noqa: B008 for row in rows ] ) + + +@router.get("/documents/content", response_model=DocContent) +def get_document_content( + source: str, path: str, db: Session = Depends(get_db) # noqa: B008 +) -> DocContent: + """Full content of one indexed document, looked up by ``(source, path)``. + + Stateless (A10) and database-only: unknown pairs β€” including traversal + strings such as ``../../etc/passwd`` β€” are just non-existent rows and + map to 404 ``{detail: "document not found"}``. + """ + row = db.execute( + select(Document, func.count(Chunk.id).label("chunks")) + .outerjoin(Chunk, Chunk.document_id == Document.id) + .where(Document.source == source, Document.path == path) + .group_by(Document.id) + ).first() + if row is None: + raise HTTPException(status_code=404, detail="document not found") + doc, chunks = row + return DocContent( + source=doc.source, + path=doc.path, + title=doc.title, + format=doc_format(doc.path), + content=doc.content, + indexed_at=doc.indexed_at.isoformat(), + chunks=chunks, + ) diff --git a/app/schemas.py b/app/schemas.py index 7cd3da4..888b2f3 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -61,3 +61,15 @@ class DocList(BaseModel): """Response of ``GET /api/docs`` (empty list β†’ designed empty state).""" documents: list[DocSummary] + + +class DocContent(BaseModel): + """One indexed document's full content (feeds the viewer page, phase 10).""" + + source: str + path: str + title: str + format: str + content: str + indexed_at: str + chunks: int diff --git a/frontend/assets/app.js b/frontend/assets/app.js index 1013c09..78a765b 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -65,46 +65,14 @@ const reducedMotion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches; const SCROLL = reducedMotion ? "auto" : "smooth"; -/* ---------- tiny, safe markdown renderer (no external libs, no CDN) ---------- */ -export function escapeHtml(s) { - return s.replace(/[&<>"']/g, (c) => ({ - "&": "&", "<": "<", ">": ">", '"': """, "'": "'", - }[c])); -} - -export function renderMarkdown(md) { - // 1. Protect fenced code blocks. - const codeBlocks = []; - let text = md.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => { - codeBlocks.push(`
${escapeHtml(code.replace(/\n$/, ""))}
`); - return `\u0000CODE${codeBlocks.length - 1}\u0000`; - }); - - // 2. Escape everything else, then apply inline + block transforms. - text = escapeHtml(text) - .replace(/`([^`\n]+)`/g, "$1") - .replace(/\*\*([^*]+)\*\*/g, "$1") - .replace(/(^|[\s(])\*([^*\n]+)\*/g, "$1$2") - .replace(/^### (.*)$/gm, "

$1

") - .replace(/^## (.*)$/gm, "

$1

") - .replace(/^# (.*)$/gm, "

$1

") - .replace(/^\s*[-*] (.*)$/gm, "
  • $1
  • ") - .replace(/(
  • [\s\S]*?<\/li>)(?!\s*
  • )/g, "") - .replace(/^\d+\. (.*)$/gm, "
  • $1
  • "); - - // 3. Paragraphs (double newline separated). - text = text - .split(/\n{2,}/) - .map((block) => { - const b = block.trim(); - if (!b) return ""; - if (/^<(h\d|ul|ol|pre|li)/.test(b)) return b; - return `

    ${b.replace(/\n/g, "
    ")}

    `; - }) - .join(""); - - // 4. Restore code blocks. - return text.replace(/\u0000CODE(\d+)\u0000/g, (_m, i) => codeBlocks[Number(i)]); +/* ---------- document viewer link (phase 10) ---------- + * Every cited document opens in the viewer, in a NEW tab. Both query + * values are percent-encoded: real paths contain slashes and sometimes + * spaces, which would otherwise corrupt the query string. (The renderer + * renderMarkdown/escapeHtml now lives in assets/markdown.js β€” a classic + * script loaded by index.html and document.html before these modules.) */ +export function documentUrl(source, path) { + return "/document.html?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path); } /* ---------- avatar glyphs (phase 08: emoji-free chrome) ---------- @@ -323,7 +291,9 @@ function appendSources(wrap, sources) { const chip = document.createElement("a"); chip.className = "source-chip"; chip.setAttribute("role", "listitem"); - chip.href = "/sources.html"; + chip.href = documentUrl(s.source, s.path); + chip.target = "_blank"; // open the full document in a new tab + chip.rel = "noopener"; chip.textContent = label; chip.title = label; meta.appendChild(chip); diff --git a/frontend/assets/document.js b/frontend/assets/document.js new file mode 100644 index 0000000..2c63f28 --- /dev/null +++ b/frontend/assets/document.js @@ -0,0 +1,116 @@ +/* Brain of Reese β€” document viewer (phase 10). + * + * Reads `source`/`path` query params, fetches the stateless content + * endpoint (GET /api/documents/content β€” database only, no filesystem), + * and renders: + * + * β€’ md / markdown β†’ the shared escape-first renderer (markdown.js) in a + * ≀46rem centered column; + * β€’ any other β†’ the raw content as a text node inside + *
     (mono, horizontal scroll).
    + *
    + * XSS-safe by construction: markdown is escaped before transform, raw
    + * formats are set via textContent, and every document-derived string
    + * (title, badges, path) is written with textContent β€” never innerHTML.
    + *
    + * A missing document (unknown pair, missing params, network error) shows
    + * the designed not-found card with a link back to the Sources page.
    + */
    +
    +const params = new URLSearchParams(window.location.search);
    +const source = params.get("source") || "";
    +const path = params.get("path") || "";
    +
    +const titleEl = document.querySelector("#doc-title");
    +const metaEl = document.querySelector("#doc-meta");
    +const contentEl = document.querySelector("#doc-content");
    +const notFoundEl = document.querySelector("#doc-not-found");
    +const mainEl = document.querySelector("#main");
    +const backLink = document.querySelector("#doc-back");
    +
    +/* Back: prefer the browser's own history when there is one (the viewer was
    + * opened from this tab's session); a fresh tab lands on the Sources page. */
    +backLink.addEventListener("click", (e) => {
    +  if (window.history.length > 1) {
    +    e.preventDefault();
    +    window.history.back();
    +  }
    +});
    +
    +function fmtDate(iso) {
    +  try {
    +    return new Date(iso).toLocaleString();
    +  } catch {
    +    return iso;
    +  }
    +}
    +
    +function contentUrl(s, p) {
    +  return "/api/documents/content?source=" + encodeURIComponent(s) + "&path=" + encodeURIComponent(p);
    +}
    +
    +function metaBadge(cls, text) {
    +  const el = document.createElement("span");
    +  el.className = cls;
    +  el.textContent = text;
    +  return el;
    +}
    +
    +function render(doc) {
    +  titleEl.textContent = doc.title;
    +  document.title = `${doc.title} Β· Brain of Reese`;
    +
    +  const pathCode = document.createElement("code");
    +  pathCode.className = "doc-path";
    +  pathCode.textContent = doc.path;
    +  metaEl.replaceChildren(
    +    metaBadge("doc-source-badge", doc.source),
    +    metaBadge("format-badge", doc.format),
    +    pathCode,
    +    metaBadge("doc-indexed", `Indexed ${fmtDate(doc.indexed_at)}`),
    +    metaBadge("doc-chunks", `${doc.chunks} chunk${doc.chunks === 1 ? "" : "s"}`),
    +  );
    +
    +  notFoundEl.hidden = true;
    +  contentEl.replaceChildren();
    +  if (doc.format === "md" || doc.format === "markdown") {
    +    const wrap = document.createElement("div");
    +    wrap.className = "doc-md";
    +    wrap.innerHTML = renderMarkdown(doc.content); // escape-first: XSS-safe
    +    contentEl.appendChild(wrap);
    +  } else {
    +    const pre = document.createElement("pre");
    +    pre.className = "doc-raw";
    +    pre.textContent = doc.content; // text node: never parsed as HTML
    +    contentEl.appendChild(pre);
    +  }
    +}
    +
    +function showNotFound() {
    +  titleEl.textContent = "Document not found";
    +  document.title = "Document not found Β· Brain of Reese";
    +  metaEl.replaceChildren();
    +  contentEl.replaceChildren();
    +  notFoundEl.hidden = false;
    +}
    +
    +async function load() {
    +  try {
    +    if (!source || !path) {
    +      showNotFound();
    +      return;
    +    }
    +    const r = await fetch(contentUrl(source, path));
    +    if (!r.ok) {
    +      showNotFound();
    +      return;
    +    }
    +    render(await r.json());
    +  } catch {
    +    showNotFound();
    +  } finally {
    +    mainEl.focus(); // move focus to main on load (a11y)
    +  }
    +}
    +
    +load();
    diff --git a/frontend/assets/markdown.js b/frontend/assets/markdown.js
    new file mode 100644
    index 0000000..adfe7e3
    --- /dev/null
    +++ b/frontend/assets/markdown.js
    @@ -0,0 +1,51 @@
    +/* Brain of Reese β€” shared markdown renderer (no external libs, no CDN).
    + *
    + * Extracted from app.js (phase 10) so the chat page and the document
    + * viewer share the exact same escape-first renderer: every character is
    + * HTML-escaped before any markup transform runs, so document (or user)
    + * content can never inject live HTML/XSS. Classic script on purpose:
    + * index.html and document.html load it via a plain relative 
    +  
    +
    +
    diff --git a/frontend/index.html b/frontend/index.html
    index 9c640b5..5da3797 100644
    --- a/frontend/index.html
    +++ b/frontend/index.html
    @@ -73,6 +73,7 @@
         
       
     
    +  
       
     
     
    diff --git a/tests/e2e/test_chat_rag.py b/tests/e2e/test_chat_rag.py
    index e4cc328..5ae4bdb 100644
    --- a/tests/e2e/test_chat_rag.py
    +++ b/tests/e2e/test_chat_rag.py
    @@ -98,10 +98,15 @@ 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).
         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", "/sources.html")
    +    expect(chip.first).to_have_attribute(
    +        "href", "/document.html?source=docs&path=homelab%2Fkubernetes.md"
    +    )
    +    expect(chip.first).to_have_attribute("target", "_blank")
    +    expect(chip.first).to_have_attribute("rel", "noopener")
     
         # Button recovers: enabled + "Send" (never stale).
         expect(page.locator("#send-btn")).to_be_enabled()
    diff --git a/tests/e2e/test_document_viewer.py b/tests/e2e/test_document_viewer.py
    new file mode 100644
    index 0000000..4769245
    --- /dev/null
    +++ b/tests/e2e/test_document_viewer.py
    @@ -0,0 +1,270 @@
    +"""Phase 10 E2E (Playwright): the clickable document viewer.
    +
    +Story: ``.agent/user_stories/document-viewer.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).
    +
    +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.
    +"""
    +from __future__ import annotations
    +
    +import asyncio
    +import re
    +from datetime import UTC, datetime
    +from pathlib import Path
    +from threading import Thread
    +from typing import Any
    +
    +from playwright.sync_api import Page, expect
    +from sqlalchemy import text
    +
    +from app.config import Settings
    +from app.db import SessionLocal
    +from app.models import Document
    +from app.rag.importer import ImportSummary, import_sources
    +from app.rag.llm import LLMClient
    +
    +REPO = Path(__file__).resolve().parents[2]
    +FIXTURES = REPO / "tests" / "fixtures" / "docs"
    +QUESTION = "How is my Kubernetes cluster set up?"
    +
    +
    +async def _import_fixtures(mock_port: int) -> ImportSummary:
    +    kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
    +    settings = Settings(**kwargs)  # pyright: ignore[reportCallIssue]
    +    return await import_sources([FIXTURES], LLMClient(settings))
    +
    +
    +def _run_in_thread(coro: Any) -> Any:
    +    """Run a coroutine on a worker thread.
    +
    +    Playwright's sync API keeps an asyncio loop running on the test thread,
    +    so ``asyncio.run`` cannot be called directly from a test body.
    +    """
    +    box: dict[str, Any] = {}
    +
    +    def runner() -> None:
    +        try:
    +            box["value"] = asyncio.run(coro)
    +        except BaseException as e:  # noqa: BLE001 β€” re-raised on the test thread
    +            box["error"] = e
    +
    +    t = Thread(target=runner)
    +    t.start()
    +    t.join()
    +    if "error" in box:
    +        raise box["error"]
    +    return box["value"]
    +
    +
    +def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
    +    """Truncate the KB (and query log), then optionally re-import fixtures."""
    +    with SessionLocal() as db:
    +        db.execute(text("TRUNCATE chunks, documents, query_log"))
    +        db.commit()
    +    if not seed:
    +        return None
    +    return _run_in_thread(_import_fixtures(mock_port))
    +
    +
    +# ---------------------------------------------------------------------------
    +# 1. Chat source chip β†’ new tab β†’ full document
    +# ---------------------------------------------------------------------------
    +
    +
    +def test_source_chip_opens_document(
    +    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, both query values encoded
    +    # (the path's slashes come out as %2F β€” exactly why encoding matters).
    +    expect(chip.first).to_have_attribute(
    +        "href", "/document.html?source=docs&path=homelab%2Fkubernetes.md"
    +    )
    +    expect(chip.first).to_have_attribute("target", "_blank")
    +    expect(chip.first).to_have_attribute("rel", "noopener")
    +
    +    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")
    +        )
    +    )
    +    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")
    +
    +
    +# ---------------------------------------------------------------------------
    +# 2. Sources table path link β†’ viewer (yaml β†’ raw pre)
    +# ---------------------------------------------------------------------------
    +
    +
    +def test_sources_row_links_to_viewer(
    +    page: Page, app_url: str, mock_llm: int, db_ready: None
    +) -> None:
    +    _reset_db(mock_llm, seed=True)
    +    page.goto(f"{app_url}/sources.html")
    +
    +    row = page.locator("#docs-tbody tr", has_text="gitlab-compose.yaml")
    +    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.
    +    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).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")
    +    # Non-markdown formats render as escaped monospace text in a pre.
    +    pre = viewer.locator("#doc-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
    +
    +
    +# ---------------------------------------------------------------------------
    +# 3. Markdown renders through the shared renderer and stays XSS-safe
    +# ---------------------------------------------------------------------------
    +
    +
    +def test_markdown_renders_and_stays_xss_safe(
    +    page: Page, app_url: str, mock_llm: int, db_ready: None
    +) -> None:
    +    _reset_db(mock_llm, seed=True)
    +    # A document whose content carries a hostile \n\nXSS-FIXTURE-MARKER",
    +                content_hash="c" * 64,
    +                indexed_at=datetime.now(UTC),
    +            )
    +        )
    +        db.commit()
    +
    +    dialogs: list[str] = []
    +
    +    def _catch_dialog(d) -> None:  # a fired dialog == executed script
    +        dialogs.append(d.message)
    +        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 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"
    +    assert dialogs == [], f"dialog fired β€” script executed: {dialogs}"
    +
    +
    +# ---------------------------------------------------------------------------
    +# 4. Missing document β†’ designed not-found state, no console crash
    +# ---------------------------------------------------------------------------
    +
    +
    +def test_missing_doc_shows_not_found(
    +    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)))
    +
    +    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")
    +    expect(card).to_be_visible()
    +    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)
    +    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)"""
    +    )
    +    assert refs, "expected local asset references on /document.html"
    +    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)
    +    expect(page.locator(".skip-link")).to_have_count(1)
    +    expect(page.locator(".doc-shell")).to_have_attribute("aria-live", "polite")
    +    assert page.evaluate("() => document.activeElement && document.activeElement.id") == "main"
    +
    +    # 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
    diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py
    index 762e12d..516f8ec 100644
    --- a/tests/integration/test_api.py
    +++ b/tests/integration/test_api.py
    @@ -52,12 +52,16 @@ def test_suggestions_honors_bor_suggestions_env_override(monkeypatch) -> None:
     
     @pytest.mark.parametrize(
         ("path", "marker"),
    -    [("/", "Brain of Reese"), ("/sources.html", "Knowledge base")],
    +    [
    +        ("/", "Brain of Reese"),
    +        ("/sources.html", "Knowledge base"),
    +        ("/document.html", "Brain of Reese"),  # phase 10: viewer page
    +    ],
     )
     def test_html_pages_served_locally_no_cdn(client, path: str, marker: str) -> None:
    -    """No-CDN check (PLAN Β§7.3, re-verified on BOTH pages in phase 07):
    -    each page is served by FastAPI and references only same-origin assets
    -    (no https:// script/link tags)."""
    +    """No-CDN check (PLAN Β§7.3, re-verified on BOTH pages in phase 07 and
    +    on the viewer page in phase 10): each page is served by FastAPI and
    +    references only same-origin assets (no https:// script/link tags)."""
         r = client.get(path)
         assert r.status_code == 200
         assert marker in r.text
    @@ -68,6 +72,9 @@ def test_html_pages_served_locally_no_cdn(client, path: str, marker: str) -> Non
     def test_styles_and_js_served(client) -> None:
         assert client.get("/assets/styles.css").status_code == 200
         assert client.get("/assets/app.js").status_code == 200
    +    assert client.get("/assets/sources.js").status_code == 200
    +    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
     
     
     # Emoji code points banned from UI chrome (phase 08): the pictograph
    @@ -92,10 +99,20 @@ def _find_emoji(text: str) -> list[str]:
     
     
     @pytest.mark.parametrize(
    -    "path", ["/", "/sources.html", "/assets/app.js", "/assets/styles.css"]
    +    "path",
    +    [
    +        "/",
    +        "/sources.html",
    +        "/document.html",
    +        "/assets/app.js",
    +        "/assets/sources.js",
    +        "/assets/markdown.js",
    +        "/assets/document.js",
    +        "/assets/styles.css",
    +    ],
     )
     def test_ui_chrome_has_no_emoji(client, path: str) -> None:
    -    """Permanent regression guard (phase 08): the UI chrome β€” both pages,
    +    """Permanent regression guard (phase 08): the UI chrome β€” all pages,
         the JS that renders it, and the stylesheet β€” is emoji-free."""
         r = client.get(path)
         assert r.status_code == 200
    diff --git a/tests/integration/test_document_content.py b/tests/integration/test_document_content.py
    new file mode 100644
    index 0000000..0395454
    --- /dev/null
    +++ b/tests/integration/test_document_content.py
    @@ -0,0 +1,137 @@
    +"""Integration tests: GET /api/documents/content β€” the viewer's data source.
    +
    +Uses the real compose Postgres (``db`` fixture) and FastAPI's TestClient:
    +* 200 with the full field set for a seeded document (all formats);
    +* 404 for an unknown (source, path) pair;
    +* 404 for traversal-style ``path`` values (no filesystem access β†’ no leak).
    +"""
    +from __future__ import annotations
    +
    +from datetime import UTC, datetime
    +
    +from sqlalchemy import text
    +
    +from app.models import Chunk, Document
    +
    +
    +def _seed_doc(
    +    db,
    +    source: str = "Homelab",
    +    path: str = "kubernetes.md",
    +    title: str = "Kubernetes Homelab Cluster",
    +    content: str = "# Kubernetes\n\nTalos on 3 nodes.",
    +    chunks: int = 2,
    +) -> None:
    +    """Truncate the KB and insert one document with ``chunks`` chunk rows."""
    +    db.execute(text("TRUNCATE chunks, documents"))
    +    db.commit()
    +    doc = Document(
    +        source=source,
    +        path=path,
    +        full_path=f"/tmp/{path}",
    +        title=title,
    +        content=content,
    +        content_hash="a" * 64,
    +        indexed_at=datetime.now(UTC),
    +    )
    +    db.add(doc)
    +    db.flush()
    +    if chunks:
    +        db.add_all(
    +            Chunk(document_id=doc.id, position=i, content=f"chunk {i}", embedding=[0.01] * 768)
    +            for i in range(chunks)
    +        )
    +    db.commit()
    +
    +
    +def test_content_200_all_fields(client, db) -> None:
    +    _seed_doc(db)
    +    try:
    +        r = client.get(
    +            "/api/documents/content",
    +            params={"source": "Homelab", "path": "kubernetes.md"},
    +        )
    +        assert r.status_code == 200
    +        body = r.json()
    +        assert set(body) == {"source", "path", "title", "format", "content", "indexed_at", "chunks"}
    +        assert body["source"] == "Homelab"
    +        assert body["path"] == "kubernetes.md"
    +        assert body["title"] == "Kubernetes Homelab Cluster"
    +        assert body["format"] == "md"
    +        assert body["content"] == "# Kubernetes\n\nTalos on 3 nodes."
    +        assert body["chunks"] == 2
    +        datetime.fromisoformat(body["indexed_at"])  # raises if not ISO-8601
    +    finally:
    +        db.execute(text("TRUNCATE chunks, documents"))
    +        db.commit()
    +
    +
    +def test_content_404_unknown_path(client, db) -> None:
    +    _seed_doc(db)
    +    try:
    +        r = client.get(
    +            "/api/documents/content",
    +            params={"source": "Homelab", "path": "nope/missing.md"},
    +        )
    +        assert r.status_code == 404
    +        assert r.json() == {"detail": "document not found"}
    +        # A pair that exists under a DIFFERENT source is also 404 β€” both
    +        # values must match the row.
    +        r = client.get(
    +            "/api/documents/content",
    +            params={"source": "Deployments", "path": "kubernetes.md"},
    +        )
    +        assert r.status_code == 404
    +    finally:
    +        db.execute(text("TRUNCATE chunks, documents"))
    +        db.commit()
    +
    +
    +def test_content_404_traversal_style_path_no_leak(client, db) -> None:
    +    """DB-only lookup: traversal strings are just non-existent rows β€” 404,
    +    and the response must not carry anything from the filesystem."""
    +    _seed_doc(db)
    +    try:
    +        for path in ("../../etc/passwd", "../kubernetes.md", "..%2F..%2Fetc%2Fpasswd"):
    +            r = client.get(
    +                "/api/documents/content",
    +                params={"source": "Homelab", "path": path},
    +            )
    +            assert r.status_code == 404, path
    +            assert r.json() == {"detail": "document not found"}, path
    +            assert "root:" not in r.text, path
    +    finally:
    +        db.execute(text("TRUNCATE chunks, documents"))
    +        db.commit()
    +
    +
    +def test_content_format_from_suffix(client, db) -> None:
    +    """format = lowercased path suffix: yaml documents (phase 09 corpus) and
    +    the no-suffix fallback both flow through the same endpoint."""
    +    try:
    +        _seed_doc(
    +            db,
    +            path="container_gitlab/gitlab-compose.yaml",
    +            title="gitlab-compose",
    +            content="services:\n  gitlab:\n    image: gitlab/gitlab-ce",
    +            chunks=0,
    +        )
    +        r = client.get(
    +            "/api/documents/content",
    +            params={"source": "Homelab", "path": "container_gitlab/gitlab-compose.yaml"},
    +        )
    +        assert r.status_code == 200
    +        body = r.json()
    +        assert body["format"] == "yaml"
    +        assert body["chunks"] == 0  # outerjoin β†’ zero, not missing
    +
    +        _seed_doc(db, path="README", title="README", content="plain text, no suffix", chunks=0)
    +        r = client.get(
    +            "/api/documents/content",
    +            params={"source": "Homelab", "path": "README"},
    +        )
    +        assert r.status_code == 200
    +        assert r.json()["format"] == "text"  # no-suffix fallback
    +    finally:
    +        db.execute(text("TRUNCATE chunks, documents"))
    +        db.commit()
    diff --git a/tests/unit/test_document_viewer.py b/tests/unit/test_document_viewer.py
    new file mode 100644
    index 0000000..ce35e66
    --- /dev/null
    +++ b/tests/unit/test_document_viewer.py
    @@ -0,0 +1,268 @@
    +"""Unit: document viewer (phase 10).
    +
    +Python side:
    +* ``doc_format`` β€” format from the path suffix (incl. ``.markdown`` and the
    +  no-suffix fallback);
    +* the content endpoint's 200/404 mapping β€” tested WITHOUT a database by
    +  stubbing the session via FastAPI's dependency override (unknown pairs and
    +  traversal-style paths map to 404 ``{detail: "document not found"}``;
    +  known pairs map to the full ``DocContent`` shape).
    +
    +Frontend side:
    +* the viewer URL builder β€” its real query-encoding behavior (paths with
    +  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), (
    +            f"{page} must load markdown.js via a relative "
    +        + "\\n\\n**bold** and `code`'));"
    +    )
    +    html = out.strip()
    +    assert "