phase: 153_document_viewer_context
Build and Push Containers / build-and-push-app (push) Successful in 2m45s
Build and Push Containers / build-and-push-db (push) Successful in 19s

That last `--co` (collect-only) run was my probe mistake — it shows 34% because no tests execute under collect-only. The valid coverage run was the real gate command executed earlier: `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (exit 0, all 3544 tests executed). All gates are verified.

## Phase 153 — final verification pass report

**Verified (all tasks 01–04 complete in `complete/`):**
- Modal `fullPageUrl(source, path, back = null)` + `openDocumentModal(…, back = null)` with module-level `currentBack`; 5 chat call sites pass `"/"`, sources.js call site unchanged (byte-identical Sources path); `documentUrl` comment now true
- Copy-path control (shared `renderDocument` core, icon+text, text = accessible name) on both surfaces; per-page clipboard helpers with the fallback-field + status-line contract; `#doc-copy-announcer` live region; styles + 44px touch floor
- Unit pins: `&back=%2F` / no-`back` byte-identity, `currentBack` single-writer guard, copy strings byte-pinned, phase-13 regression pins untouched

**Test / lint / coverage results:**
- `uv run pytest` → **3544 passed**, 0 failed (full unit + integration)
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90% gate)
- `uv run pytest tests/e2e/test_document_viewer_context.py -v --no-cov` → **8 passed** in 47.33s, in isolation (DB up, mock LLM)
- `uv run ruff check . && uv run pyright` → clean (0 errors/warnings)

**Completion criteria:** all met — (1) chat→modal→Full page: `href="/"` + "Chat"; Sources→modal→Full page: `href="/sources.html"` + "Sources" with `back=` asserted absent; (2) no-`back` URLs byte-identical to pre-phase; (3) Copy path: "Path copied." on success (clipboard readback), prefilled select-on-focus fallback field + "Path ready — copy it from the field." on rejected clipboard, both surfaces parameterized; (4) copied string exactly `docs/homelab/kubernetes.md`; (5) full suite green, E2E isolated green, lint clean, coverage 99%; (6) commit by harness.

**Documentation:** README.md reviewed in full — phase-153 feature already named in the Chat section; routes, env reference, workflows, deployment all consistent — no changes needed. AGENTS.md reviewed in full — rules, gates, and quick-reference commands verified against the repo (incl. `test_smoke.py`, `.env.example`, gitignore entries) — consistent, no changes needed.

**Notable:** pytest 9.1.1 quirk — a doubled `-q` (pyproject `addopts` + explicit `-q`) swallows the final "N passed" summary line while still exiting 0; the plain `uv run pytest` gate command prints it. No defects found in prior work.

**Next pending phase:** `154_sync_status_freshness`.
This commit is contained in:
2026-10-04 09:23:54 -04:00
parent 12fa69b7ea
commit 966c4b0540
59 changed files with 4304 additions and 34 deletions
+140 -11
View File
@@ -9,7 +9,8 @@
* page bundle, same single-evaluation design as header.js).
*
* Contract:
* • openDocumentModal(source, path, triggerEl) — shows the #doc-modal
* • 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
@@ -38,8 +39,29 @@
* 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=… URL the chip/table link carries,
* so the no-JS / direct-link page is always one click away.
* /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
@@ -84,15 +106,30 @@ let lastPrev = null;
* 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). */
* 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) {
return "/document.html?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) {
@@ -107,6 +144,91 @@ 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();
@@ -163,13 +285,19 @@ function onModalKeydown(e) {
/* 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. */
export function openDocumentModal(source, path, trigger = null) {
* 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.
window.open(fullPageUrl(source, path), "_blank", "noopener");
// 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 —
@@ -199,7 +327,7 @@ export function openDocumentModal(source, path, trigger = null) {
modalEl.hidden = false; // .doc-modal[hidden] { display:none } — visible now
showLoading();
if (openEl) {
openEl.href = fullPageUrl(source, path); // the "Full page" escape hatch
openEl.href = fullPageUrl(source, path, currentBack); // the "Full page" escape hatch
openEl.hidden = false;
}
document.addEventListener("keydown", onModalKeydown, true); // Escape + Tab trap
@@ -216,6 +344,7 @@ export function openDocumentModal(source, path, trigger = null) {
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(() => {