Files
brain-of-reese/frontend/assets/document-modal.js
T
ducoterra b0c7aed613
Build and Push Containers / build-and-push-app (push) Successful in 1m44s
Build and Push Containers / build-and-push-db (push) Successful in 11s
phase: 165_prose_word_wrap
All verification complete. Final report:

**Phase 165 — Prose word wrap (final verification pass)**
- Verified: `PROSE_FORMATS={txt,text}` + `isProseDoc` (single-site, image arm wins); `renderDocument` three-way branch (md → `.doc-md`, prose → `.doc-prose` text node, else `pre.doc-raw` byte-identical); `.doc-prose` CSS = 72rem column, `pre-wrap` + `overflow-wrap: anywhere`, tokens only, zero literals; `[data-view="pages"]` hide list includes `.doc-prose` as one grouped declaration; phase-116 scrollbar rules intact and modal-scoped; no `innerHTML` in the prose path.
- Tests/lint (all 100% green):
  - `uv run pytest` → **3854 passed** (unit + integration)
  - `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90%)
  - `uv run ruff check .` → All checks passed; `uv run pyright` → **0 errors**
  - `uv run pytest tests/e2e/test_prose_word_wrap.py -v --no-cov` → **5 passed** (isolation)
  - Isolation regressions: document_viewer 8, image_documents 4, pdf_pages_in_sources 4, pdf_strip_additive_selfheal 5, edit_summaries 4, summary_in_viewer 4, chat_answer_clusters 4, garbage_pdf 3, pdf_support 6, sticky_navbar 3, smoke 3 — all passed
- Completion criteria: image description wraps, full text, both surfaces ✔; `.txt` wraps same way ✔; `py`/`json`/code keep `pre.doc-raw` + horizontal overflow + themed modal scrollbar (E2E negative control + pins) ✔; no innerHTML, AA `--ink`/`--surface` pair ✔; full gates green ✔; commit + phase-dir move are harness-owned (not executed per pipeline rules).
- Documentation: README already updated by task 03 (viewer section + image-docs section); full README/AGENTS.md review found no further inconsistencies — **verified consistent, no changes needed**.
- Notable deviations: `.doc-prose` omits `font-family: inherit` from 00_phase D2's snippet — task 01's authoritative "exact property set" excludes it and `div` inherits fonts anyway (behavior identical). PDF deferral (A4) intact: `format == "pdf"` stays in `.doc-raw`, owner call recorded.
- Next pending phase: `166_hide_empty_answer_bubble`.
2026-10-07 22:57:40 -04:00

417 lines
19 KiB
JavaScript

/* Brain of Reese — document modal (phase 26, task 02).
*
* "New documents should open in an almost-fullscreen modal, not in a new
* page" (TODO.md L4). This module is the SINGLE owner of the modal (the
* way header.js owns the shared header): the chat page (app.js source
* chips) and the Sources page (sources.js table links) each import
* openDocumentModal(...) from here — one implementation, no duplicate
* module instance, no direct <script> tag (esbuild inlines it into the
* page bundle, same single-evaluation design as header.js).
*
* Contract:
* • openDocumentModal(source, path, triggerEl, backEl = null) —
* shows the #doc-modal
* overlay (the phase-26 task-01 skeleton) in its loading state,
* moves focus into #doc-modal-content (tabindex="-1" — programmatic
* focus target), fetches GET /api/documents/content (the SAME
* stateless endpoint the /document.html page uses — identical
* percent-encoding), and renders through document.js's shared
* renderDocument: md/markdown → .doc-md via the escape-first
* renderer, the prose set (phase 165 — image descriptions,
* txt/text) → <div class="doc-prose">, other formats →
* <pre class="doc-raw"> via textContent.
* A !ok / network failure renders a short "document not found" line
* in the content area.
* • closeDocumentModal() — hides the overlay, removes the Escape/Tab
* capture, and restores focus to the element that opened the modal
* (best-effort: the trigger may have been removed from the DOM, e.g.
* "New chat" clearing the list while the modal is open).
* • Close affordances (modal UX standard): #doc-modal-close button,
* backdrop click, and the Escape key (captured on document, so it
* works no matter where focus is). While open, Tab / Shift+Tab are
* trapped inside the panel.
* • History (phase 144, TODO L6): the open owns ONE state-only
* entry ({ modal: "doc", source, path } — the URL does not move, so
* the router's pathname-only popstate is a no-op and the Sources
* drill re-adopts the state it was already showing): Back closes
* the modal (view/URL/drill untouched), a button/Escape/backdrop
* close removes the entry IN PLACE (the next Back behaves as if the
* modal never opened), and Forward onto the entry re-opens the same
* document (the browser owns its entries — the phase-99 D2
* convention).
* • #doc-modal-open ("Full page") is the escape hatch to the
* dedicated viewer: its href is set on open to the same
* /document.html?source=…&path=…&back=… URL the chip/table link
* carries (phase 153 — the open's `back` rides through
* fullPageUrl, the app.js::documentUrl contract; a null `back`
* keeps the URL byte-identical — the Sources path), so the
* no-JS / direct-link page is always one click away.
* • "Copy path" (phase 153, task 02): the meta block's
* icon+text control (built by document.js's shared
* renderDocument core — the SAME shape as /document.html's
* titlebar; this module wires it with the modal's OWN
* page-local clipboard helper — the per-page duplication
* house style, no new shared module). The copied string is
* `source + "/" + path` (ASSUMPTION A1 — the read-tool form;
* the same two values the meta row renders); success shows a
* transient "Path copied." status (auto-clears ~4s, the toast
* idiom — visual only) and #doc-modal-desc reads the outcome
* (one a11y announcer); the rejected-clipboard failure
* renders the inline fallback field (input-like, prefilled,
* select-on-focus — the .share-link-fallback pattern) + the
* "Path ready — copy it from the field." status line INSIDE
* #doc-modal-meta (the block wraps — no clip rule; the nodes
* sit right after the control). A failed copy never clears
* the previous state into nothing (replaced in place); a
* re-click re-attempts.
*
* Import safety: the top level only binds controls that exist on the
* page (a missing element is a no-op — the header.js pattern). document
* .js is imported for renderDocument alone; its /document.html-specific
* init is guarded there, so importing this module on the chat/sources
* pages has no side effects beyond binding the modal itself.
*/
import { renderDocument } from "./document.js";
const modalEl = document.querySelector("#doc-modal");
const backdropEl = document.querySelector("#doc-modal-backdrop");
const panelEl = document.querySelector("#doc-modal-panel");
const titleEl = document.querySelector("#doc-modal-title");
const metaEl = document.querySelector("#doc-modal-meta");
const contentEl = document.querySelector("#doc-modal-content");
const openEl = document.querySelector("#doc-modal-open");
const closeEl = document.querySelector("#doc-modal-close");
const descEl = document.querySelector("#doc-modal-desc");
/* The element that opened the modal — closeDocumentModal restores focus
* to it (best-effort, see the header note). */
let triggerEl = null;
/* Monotonic fetch sequence: a close or a re-open for another document
* must not render a stale response into the modal (rapid
* open → close → open). */
let fetchSeq = 0;
/* The modal's history entry (phase 144, TODO L6): { prev, source,
* path } while the modal is open — prev is the below-entry to restore
* on a non-back close; source/path name the doc the entry carries.
* Written ONLY by openDocumentModal (push on a normal open, adopt on
* the forward re-open, in-place replace on a defensive re-open-while-
* open); nulled by closeDocumentModal. */
let entry = null;
/* The below-entry remembered at each back-close (the popstate handler
* is its only writer): a forward re-open adopts it as the entry's
* prev — at that moment the browser sits ON the modal entry, so the
* below-entry is known only from here. Null/stale otherwise. */
let lastPrev = null;
/* True ONLY around the popstate handler's closeDocumentModal call
* (a back press already moved us off the entry, so that close must
* not replaceState it again). Set and reset by the popstate handler
* alone. */
let closingViaBack = false;
/* The `back` the CURRENT open carries to the "Full page" escape hatch
* (phase 153, task 01): the modal is one-at-a-time by its own
* contract, so a single variable is sound. Reset at the top of EVERY
* openDocumentModal (a null back keeps the URL byte-identical — the
* Sources path); the no-skeleton fallback and the anchor both read it. */
let currentBack = null;
/* Same encoding the chips and the Sources table links use — both query
* values percent-encoded (real paths contain slashes, sometimes spaces).
* Phase 153 (task 01): `fullPageUrl`'s optional `back` (default null)
* is appended + encoded ONLY when truthy — a null/absent `back` keeps
* the URL byte-identical to pre-phase (the Sources path — the
* viewer's /sources.html default). The encoding is the one
* app.js::documentUrl documents (percent-encoded query values; `back`
* is a same-origin relative URL, never a URL with its own query). */
function contentUrl(source, path) {
return "/api/documents/content?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
}
function fullPageUrl(source, path, back = null) {
let url =
"/document.html?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
if (back) url += "&back=" + encodeURIComponent(back);
return url;
}
function statusLine(text) {
const p = document.createElement("p");
p.className = "doc-modal-loading";
p.setAttribute("role", "status");
p.textContent = text;
return p;
}
function setDesc(text) {
if (descEl) descEl.textContent = text; // the #doc-modal-desc role=status announcer
}
/* The pending auto-clear (the copy success beat) — the modal's
* page-local copy state (phase 153, task 02). */
let modalCopyTimer = 0;
/* Copy path (phase 153, task 02): the modal's page-local helper
* (the per-page duplication house style — document.js keeps the
* /document.html page's own copy; no new shared module). The
* copied string is `source + "/" + path` (ASSUMPTION A1 — the
* read-tool form; the same two values the meta row renders).
* The clipboard contract is the share-link idiom: writeText in a
* try — a non-secure (http) homelab origin rejects the clipboard,
* so the failure path renders the inline fallback field
* (input-like, prefilled with the full path, selects its full
* content on focus — the .share-link-fallback pattern) + the
* status line "Path ready — copy it from the field." (the
* owner-locked fallback wording, path-adapted) INSIDE
* #doc-modal-meta (the block wraps — no clip rule); success shows
* a transient visible "Path copied." status (auto-clears ~4s, the
* toast idiom — the node is aria-hidden, visual only). The nodes
* land right after the "Copy path" control. #doc-modal-desc
* (setDesc) reads the outcome — one a11y announcer (the share
* pattern: the visible node is aria-hidden). A failed copy NEVER
* clears the previous state into nothing (the old field is
* replaced in place, never dropped); a re-click re-attempts. */
async function copyPathWithFallback(metaEl, path) {
/* A re-click re-attempts: the previous state (the status line
* and/or the fallback field) is replaced in place — a failed
* copy never clears the previous state into nothing (the field
* is swapped for a new one, never dropped). */
metaEl
.querySelectorAll(".doc-copy-status, .doc-path-fallback")
.forEach((el) => el.remove());
let ok = true;
try {
await navigator.clipboard.writeText(path);
} catch {
ok = false;
}
const status = document.createElement("p");
status.className = "doc-copy-status";
status.setAttribute("aria-hidden", "true"); // visual only — #doc-modal-desc is the announcer
status.textContent = ok ? "Path copied." : "Path ready — copy it from the field.";
const btn = metaEl.querySelector(".doc-copy-path");
if (ok) {
if (btn) btn.insertAdjacentElement("afterend", status);
else metaEl.appendChild(status);
setDesc("Path copied.");
clearTimeout(modalCopyTimer);
modalCopyTimer = setTimeout(() => {
if (status.isConnected) status.remove();
}, 4000); // auto-clear ~4s (the toast idiom)
} else {
const field = document.createElement("input");
field.type = "text";
field.readOnly = true;
field.className = "doc-path-fallback";
field.value = path;
field.setAttribute("aria-label", "Document path");
field.title = "Document path — click, then copy (Ctrl/Cmd+C)";
field.addEventListener("focus", () => field.select());
if (btn) {
btn.insertAdjacentElement("afterend", status);
btn.insertAdjacentElement("afterend", field); // field first, status after
} else {
metaEl.append(field, status);
}
setDesc("Path ready — copy it from the field.");
field.focus({ preventScroll: true }); // selects the path — ready to copy
}
return ok;
}
/* Phase 153 (task 02): wire the shared core's "Copy path" control
* (renderDocument built it in the meta block) with the modal's
* page-local helper — the copied string is the same two values the
* meta row renders. */
function wireCopyPath(metaEl, doc) {
const btn = metaEl.querySelector(".doc-copy-path");
if (!btn) return;
btn.addEventListener(
"click",
() => void copyPathWithFallback(metaEl, doc.source + "/" + doc.path),
);
}
function showLoading() {
if (titleEl) titleEl.textContent = "Loading…";
if (metaEl) metaEl.replaceChildren();
if (contentEl) contentEl.replaceChildren(statusLine("Loading document…"));
setDesc("Document content is loading.");
}
function renderNotFound(path) {
if (titleEl) titleEl.textContent = "Document not found";
if (metaEl) metaEl.replaceChildren();
if (contentEl) {
contentEl.replaceChildren(
statusLine(`“${path}” was not found — it may have been removed in a re-import.`),
);
}
setDesc("Document not found.");
}
/* Focus trap while the modal is open: Tab / Shift+Tab cycle within the
* panel (the visible controls are "Full page" + Close; the content
* target is programmatic-focus only, tabindex="-1"). */
const FOCUSABLE =
'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])';
function onModalKeydown(e) {
if (e.key === "Escape") {
e.preventDefault();
closeDocumentModal();
return;
}
if (e.key !== "Tab" || !panelEl) return;
const focusable = Array.from(panelEl.querySelectorAll(FOCUSABLE)).filter(
(el) => el.getClientRects().length > 0,
);
if (!focusable.length) {
e.preventDefault();
if (contentEl) contentEl.focus({ preventScroll: true });
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
const inside = active !== null && panelEl.contains(active);
if (e.shiftKey) {
if (!inside || active === first) {
e.preventDefault();
last.focus();
}
} else if (!inside || active === last) {
e.preventDefault();
first.focus();
}
}
/* Show the modal for (source, path) and fetch + render the document.
* `trigger` (the chip / table link that was clicked) is remembered so
* closeDocumentModal can return focus to it. `back` (phase 153, task
* 01) is the origin the "Full page" escape hatch (and the no-skeleton
* fallback) carries to the viewer — the chat call sites pass "/" (the
* shell's home route, the value documentUrl uses); a call site that
* omits it (the Sources table) keeps the URL byte-identical and gets
* the viewer's /sources.html default. */
export function openDocumentModal(source, path, trigger = null, back = null) {
currentBack = back; // reset at the TOP of every open (one-at-a-time)
if (!modalEl || !titleEl || !metaEl || !contentEl) {
// No modal skeleton on this page (should not happen for the pages
// that import this module) — fall back to the dedicated viewer
// instead of swallowing the click (the stored back rides along).
window.open(fullPageUrl(source, path, currentBack), "_blank", "noopener");
return;
}
/* Phase 144 (TODO L6): the modal owns ONE state-only history entry —
* Back closes the modal instead of navigating the view behind it.
* The entry is state-only (second arg "" — the URL does not move),
* so the router's pathname-only popstate is a no-op and the Sources
* drill re-adopts the state it was already showing (the sources.js
* state-only-entry convention, L1539). The current entry is a modal
* entry only on the forward re-open (the popstate handler below
* calls us sitting on it) — then we ADOPT it; otherwise we push. */
const st = history.state;
if (entry) {
// Defensive re-open while open: replace our entry in place (the
// remembered prev is kept) — never stack a second entry.
entry.source = source;
entry.path = path;
history.replaceState({ modal: "doc", source, path }, "");
} else if (st && st.modal === "doc") {
// Forward onto our own entry: adopt — the browser already sits on
// it (lastPrev is the below-entry remembered at the back-close).
entry = { prev: lastPrev, source, path };
} else {
entry = { prev: st, source, path };
history.pushState({ modal: "doc", source, path }, "");
}
triggerEl = trigger && typeof trigger.focus === "function" ? trigger : null;
modalEl.hidden = false; // .doc-modal[hidden] { display:none } — visible now
showLoading();
if (openEl) {
openEl.href = fullPageUrl(source, path, currentBack); // the "Full page" escape hatch
openEl.hidden = false;
}
document.addEventListener("keydown", onModalKeydown, true); // Escape + Tab trap
contentEl.focus({ preventScroll: true }); // a11y: focus moves INTO the dialog
const seq = ++fetchSeq;
fetch(contentUrl(source, path))
.then(async (r) => {
if (seq !== fetchSeq) return; // stale — closed or re-opened meanwhile
if (!r.ok) {
renderNotFound(path);
return;
}
const doc = await r.json();
if (seq !== fetchSeq) return;
renderDocument(doc, { titleEl, metaEl, contentEl });
wireCopyPath(metaEl, doc); // phase 153 (task 02): the Copy path control
setDesc("Document loaded.");
})
.catch(() => {
if (seq === fetchSeq) renderNotFound(path);
});
}
/* Hide the modal, drop the Escape/Tab capture, and restore focus to the
* triggering control (best-effort — it may have been removed from the
* DOM since the modal opened). */
export function closeDocumentModal() {
if (!modalEl || modalEl.hidden) return;
/* Phase 144: a NON-back close removes the modal's entry IN PLACE —
* no popstate fires, no other listener runs, the view state is back
* byte-for-byte, so the next Back behaves as if the modal never
* opened. A back close (closingViaBack — the popstate handler) has
* already been moved off the entry by the browser: no replace. */
if (entry && !closingViaBack && history.state && history.state.modal === "doc") {
history.replaceState(entry.prev, "");
}
entry = null;
fetchSeq += 1; // any in-flight fetch is stale now
modalEl.hidden = true;
document.removeEventListener("keydown", onModalKeydown, true);
if (openEl) {
openEl.hidden = true;
openEl.removeAttribute("href");
}
if (triggerEl && typeof triggerEl.focus === "function" && document.contains(triggerEl)) {
triggerEl.focus({ preventScroll: true });
}
triggerEl = null;
}
/* Top-level bindings — only on pages that carry the modal skeleton
* (a missing element is a no-op, the header.js pattern). The Escape /
* Tab capture is per-open (added/removed in open/close), so it never
* leaks into the page behind a closed modal. */
if (closeEl) closeEl.addEventListener("click", closeDocumentModal);
if (backdropEl) backdropEl.addEventListener("click", closeDocumentModal);
/* Phase 144 (TODO L6): back/forward onto the modal's entry. popstate
* fires with the state of the entry we LANDED on. Back: the modal is
* open and we landed on a non-modal state — the browser moved off our
* entry; close the modal (remember the landed state as lastPrev for
* the forward re-open; closingViaBack keeps the close from also
* replaceState-ing). Forward: the modal is closed and we landed on a
* modal entry — the browser owns its entries (the phase-99 D2
* convention) — re-open the doc the entry names (the open adopts the
* entry — no second push). Every other popstate (the router's view
* entries, the drill's kb entries) passes through untouched. A missing
* skeleton is a no-op via the entry/modalEl guards (the header.js
* pattern). */
window.addEventListener("popstate", (event) => {
const st = event.state;
const isModalEntry = st && st.modal === "doc";
if (isModalEntry && modalEl && modalEl.hidden) {
openDocumentModal(st.source, st.path, null);
return;
}
if (!isModalEntry && modalEl && !modalEl.hidden && entry) {
lastPrev = st;
closingViaBack = true;
closeDocumentModal();
closingViaBack = false;
}
});