feat(ui): documents open in an almost-fullscreen modal instead of a new page — same-page overlay on chat + Sources, /document.html kept as the no-JS/direct-link fallback

This commit is contained in:
2026-08-25 13:45:57 -04:00
parent 476aa0e066
commit fcde1fd37b
18 changed files with 1307 additions and 258 deletions
+149 -8
View File
@@ -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 ``<script src>`` before the module
scripts.
loaded by the pages that use it via a relative ``<script src>`` before
the module scripts;
* phase 26 — the shared ``renderDocument`` export in ``document.js``, the
import-safe page guard, the modal skeleton on chat + Sources, and the
modal module's close/focus/URL contract.
"""
from __future__ import annotations
@@ -37,6 +40,9 @@ APP_JS = FRONTEND / "assets" / "app.js"
SOURCES_JS = FRONTEND / "assets" / "sources.js"
DOCUMENT_JS = FRONTEND / "assets" / "document.js"
MARKDOWN_JS = FRONTEND / "assets" / "markdown.js"
MODAL_JS = FRONTEND / "assets" / "document-modal.js" # phase 26: the modal owner
INDEX_HTML = FRONTEND / "index.html"
SOURCES_HTML = FRONTEND / "sources.html"
HAVE_NODE = shutil.which("node") is not None
@@ -160,30 +166,38 @@ def test_content_requires_both_params() -> None:
def test_viewer_url_builder_present_in_chat_and_sources() -> None:
"""Both entry points (chat chips, Sources rows) build the same
encoded viewer URL and open it in a new tab with rel=noopener.
encoded viewer URL — kept as each link's ``href`` (no-JS /
context-menu escape hatch to the dedicated viewer).
Phase 13: the chat builder additionally carries ``back=/`` (encoded
%2F) so the viewer's back button returns to the chat; Sources links
intentionally omit the param (the viewer's /sources.html default)."""
intentionally omit the param (the viewer's /sources.html default).
Phase 26: the left-click no longer opens a new tab — it is
intercepted (preventDefault) and routed to openDocumentModal from
the shared modal module; no ``target="_blank"" survives on either
entry point."""
for name, js in (("app.js", _read(APP_JS)), ("sources.js", _read(SOURCES_JS))):
assert '"/document.html?source=" + encodeURIComponent(' in js, name
assert '"&path=" + encodeURIComponent(' in js, name
assert 'target = "_blank"' not in js, f"{name}: phase 26 — no new tabs"
app_js = _read(APP_JS)
# Chat: 3-arg builder with back defaulting to the chat page.
assert 'function documentUrl(source, path, back = "/")' in app_js
assert '"&back=" + encodeURIComponent(back)' in app_js
assert 'chip.href = documentUrl(s.source, s.path, "/")' in app_js
assert 'chip.target = "_blank"' in app_js
assert 'chip.rel = "noopener"' in app_js
assert 'chip.addEventListener("click"' in app_js
assert "e.preventDefault()" in app_js
assert "openDocumentModal(s.source, s.path, chip)" in app_js
sources_js = _read(SOURCES_JS)
# Sources: unchanged 2-arg builder — no back param in the URL.
assert "function documentUrl(source, path)" in sources_js
assert 'link.className = "doc-link"' in sources_js
assert "link.href = documentUrl(d.source, d.path)" in sources_js
assert 'link.target = "_blank"' in sources_js
assert 'link.rel = "noopener"' in sources_js
assert 'link.addEventListener("click"' in sources_js
assert "openDocumentModal(d.source, d.path, link)" in sources_js
# The full path stays the hover name on the ellipsized cell AND the link.
assert "pathTd.title = d.path" in sources_js
assert "link.title = d.path" in sources_js
@@ -269,6 +283,133 @@ def test_markdown_renderer_stays_xss_safe_and_unchanged() -> None:
assert "<h3>Title</h3>" in html
# ---------------------------------------------------------------------------
# Phase 26 — shared renderDocument + the document modal wiring
# ---------------------------------------------------------------------------
def test_render_document_exported_and_modal_imports_it() -> None:
"""Phase 26: document.js EXPORTS renderDocument(doc, { … }) — the
exact renderer the standalone page and the modal share (no drift).
The modal module imports it relatively, and BOTH page scripts import
the modal module relatively — no direct <script> tag (the header.js
single-evaluation design: esbuild inlines it into the page bundle,
one module instance per page)."""
doc_js = _read(DOCUMENT_JS)
# Task 03 signature: the page passes its #doc-title / #doc-meta /
# #doc-content elements under exactly these names.
assert "export function renderDocument(doc, { titleEl, metaEl, contentEl })" in doc_js, (
"document.js must export renderDocument(doc, { titleEl, metaEl, contentEl })"
)
# The standalone page renders through the SAME shared function with its
# own page elements (no second renderer copy).
assert "renderDocument(doc, { titleEl, metaEl, contentEl })" in doc_js
modal_js = _read(MODAL_JS)
assert 'from "./document.js"' in modal_js
assert "export function openDocumentModal(" in modal_js
assert "export function closeDocumentModal(" in modal_js
for name, js in (("app.js", _read(APP_JS)), ("sources.js", _read(SOURCES_JS))):
assert 'from "./document-modal.js"' in js, (
f"{name}: must import the modal module relatively"
)
for page in (INDEX_HTML, SOURCES_HTML):
text = _read(page)
assert not re.search(r"<script[^>]*document-modal\.js", text), (
f"{page.name}: no direct document-modal.js <script> tag "
"(single-evaluation design — the page script imports it)"
)
def test_document_js_page_init_is_import_safe() -> None:
"""Phase 26: the /document.html-specific init (back-link resolution,
whoami, content load) runs ONLY when #doc-title exists — the modal
module's `import { renderDocument } from "./document.js"` on the
chat/sources pages must have no side effects."""
js = _read(DOCUMENT_JS)
guard = js.find('querySelector("#doc-title")')
back_href = js.find("backLink.href = backTarget")
load_call = js.rfind("load();")
assert 0 < guard < back_href < load_call, (
"the viewer-page init must sit inside the #doc-title guard "
"(after it, and load() must be the guarded entry point)"
)
def test_both_pages_carry_the_modal_skeleton() -> None:
"""Phase 26: chat AND Sources ship the same modal skeleton (the
task-01 markup) — the a11y frame included: role=dialog +
aria-modal, a labelled close control, a focusable content target
(tabindex=-1), and a role=status announcer. Hidden by default —
inert until JS opens it."""
for page in (INDEX_HTML, SOURCES_HTML):
text = _read(page)
assert '<div class="doc-modal" id="doc-modal" hidden>' in text, page.name
assert 'id="doc-modal-backdrop"' in text, page.name
assert 'id="doc-modal-panel"' in text, page.name
assert 'role="dialog"' in text and 'aria-modal="true"' in text, page.name
assert 'id="doc-modal-title"' in text, page.name
assert 'id="doc-modal-meta"' in text, page.name
assert 'id="doc-modal-desc"' in text, page.name
assert 'id="doc-modal-open"' in text, page.name
assert re.search(r'id="doc-modal-content"[^>]*tabindex="-1"', text), page.name
assert re.search(
r'id="doc-modal-close"[^>]*aria-label="Close document"', text
), page.name
def test_sources_page_loads_markdown_before_its_module() -> None:
"""Phase 26: the modal renders md documents on the Sources page too —
so sources.html loads the classic markdown.js (global renderMarkdown)
via a relative <script src> BEFORE its module script, exactly like
index.html does."""
html = _read(SOURCES_HTML)
assert re.search(r'<script src="assets/markdown\.js"></script>', 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