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
+179
View File
@@ -159,6 +159,34 @@
* pages). ONE shared core — the modal AND /document.html get it (no
* per-surface copy); createElement + properties/textContent only (the
* never-build-HTML rule — no innerHTML).
*
* Phase 153 (task 02): the meta row's "Copy path" control — the
* read-tool `source/path` form in the clipboard with one click
* (ASSUMPTION A1 — the exact `source/path` the read tool, the
* chips, and the Sources table use; the paste target is the
* owner's own tooling). The control is built by the ONE shared
* renderDocument core (both surfaces — modal AND /document.html —
* get the SAME icon+text button, AFTER the path code and BEFORE
* the origin anchor), and each surface wires it with its OWN
* page-local clipboard helper (the per-page duplication house style
* — document-modal.js carries the modal's copy; no new shared
* module): navigator.clipboard.writeText in a try — a non-secure
* (http) homelab origin rejects the clipboard, so the failure path
* renders an inline fallback field (input-like, prefilled with the
* path, selects its full content on focus — the .share-link-
* fallback pattern) + the status line (the owner-locked fallback
* wording, path-adapted); success shows a transient VISIBLE status
* (auto-clears ~4s, the toast idiom — the node is aria-hidden,
* visual only) and the surface's role=status live region reads the
* same text (one a11y announcer — the share pattern: #doc-copy-
* announcer on the page, #doc-modal-desc in the modal). A failed
* copy NEVER clears the previous state into nothing (the old state
* is replaced in place, never dropped); a re-click re-attempts.
* The page's status/field float in a .doc-copy-pop block anchored
* under the button (the meta row must never wrap or grow the bar
* — the phase-12/34 contract — so the block is absolutely
* positioned in the header, never in the row); the modal's sit
* inline in the wrapping #doc-modal-meta block.
*/
import { bindSharedHeaderControls, fetchIsAdmin, initSharedHeader } from "./header.js";
@@ -225,6 +253,45 @@ function originAnchor(doc) {
return a;
}
/* Phase 153 (task 02): the meta row's "Copy path" control — the
* icon+text row-action shape (the git-sources.js row-buttons idiom),
* createElementNS-built (the never-build-HTML rule stands — the
* module's ONE innerHTML stays the escape-first markdown render).
* The VISIBLE "Copy path" text IS the accessible name (a redundant
* aria-label is not carried — WCAG 4.1.2 via the text), the icon is
* aria-hidden, and type=button (no form submission). The shared
* renderDocument core builds it (both surfaces — modal AND
* /document.html — get the same shape; each surface wires it with
* its own page-local clipboard helper — the per-page duplication
* house style). */
function copyPathButton() {
const SVG_NS = "http://www.w3.org/2000/svg";
const b = document.createElement("button");
b.type = "button";
b.className = "doc-copy-path";
const icon = document.createElementNS(SVG_NS, "svg");
icon.setAttribute("aria-hidden", "true");
icon.setAttribute("viewBox", "0 0 24 24");
icon.setAttribute("fill", "none");
icon.setAttribute("stroke", "currentColor");
icon.setAttribute("stroke-width", "1.8");
icon.setAttribute("stroke-linecap", "round");
icon.setAttribute("stroke-linejoin", "round");
const backRect = document.createElementNS(SVG_NS, "rect");
backRect.setAttribute("x", "9");
backRect.setAttribute("y", "9");
backRect.setAttribute("width", "11");
backRect.setAttribute("height", "11");
backRect.setAttribute("rx", "2");
const front = document.createElementNS(SVG_NS, "path");
front.setAttribute("d", "M5 15V5a2 2 0 0 1 2-2h10");
icon.append(backRect, front);
const label = document.createElement("span");
label.textContent = "Copy path";
b.append(icon, label);
return b;
}
/* ---------- shared renderer (phase 26, task 02) ----------
* Populates the three elements every render surface provides: a title,
* a .doc-meta badge row (source · format · mono path · created ·
@@ -245,6 +312,14 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
const pathCode = document.createElement("code");
pathCode.className = "doc-path";
pathCode.textContent = doc.path;
// Phase 153 (task 02): the "Copy path" control — the shared core
// builds it (BOTH surfaces — modal AND /document.html — get the
// SAME shape; each surface wires it with its own page-local
// clipboard helper). It lands AFTER the path code and BEFORE the
// origin anchor — one more inline item in the row (the modal's
// wrap rule and the page's clip rule both keep it on their
// shapes).
const copyBtn = copyPathButton();
// Phase 145 (task 03): the "Original" anchor — the git origin as a
// copyable external link, present ONLY when the content payload
// carries origin_url (a git-sourced document's sanitized repository
@@ -258,6 +333,7 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
metaBadge("doc-source-badge", doc.source),
metaBadge("format-badge", doc.format),
pathCode,
copyBtn,
...originItems,
// Phase 106 (task 08, D8): the Created badge BEFORE the Indexed
// one (the owner's verbatim position — the date at the top of a
@@ -835,10 +911,113 @@ if (document.querySelector("#doc-title")) {
: "Back";
}
/* ---------- Copy path (phase 153, task 02) ----------
* The meta row's "Copy path" control (renderDocument — the shared
* core — built it after the path code; this page wires it with
* the page-local helper — the per-page duplication house style:
* document-modal.js carries the modal's own copy, no new shared
* module). The copied string is `source + "/" + path` (ASSUMPTION
* A1 — the exact read-tool form; the same two values the meta row
* renders). navigator.clipboard.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). Success: a transient VISIBLE status "Path
* copied." (auto-clears ~4s, the toast idiom — the node is
* aria-hidden, visual only) + the page's #doc-copy-announcer
* (role=status, the visually-hidden node added for this phase) reads
* the same text — one a11y announcer (the share pattern). The
* status/field float in a .doc-copy-pop block anchored under the
* button (the meta row must never wrap or grow the bar — the
* phase-12/34 contract — so the block is absolutely positioned in
* the header, never in the row). A failed copy NEVER clears the
* previous state into nothing (the old block is replaced in
* place); a re-click re-attempts. */
let copyStatusTimer = 0; // the pending auto-clear (the success beat)
/* The inline fallback field (the .share-link-fallback pattern,
* input variant): input-like, prefilled with the full path,
* selects its full content on focus (click or Tab, then
* Ctrl/Cmd+C). readOnly — the field is a copy surface, not an
* editor. value, never innerHTML (the path is data — the XSS
* contract, unchanged). */
function makePathFallbackField(path) {
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());
return field;
}
async function copyPathWithFallback(btn, path) {
/* A re-click re-attempts: the previous block (success or
* failure) is replaced in place — a failed copy never clears
* the previous state into nothing (the field is swapped for a
* new one, never dropped). */
document.querySelectorAll(".doc-copy-pop").forEach((el) => el.remove());
let ok = true;
try {
await navigator.clipboard.writeText(path);
} catch {
ok = false;
}
const block = document.createElement("div");
block.className = "doc-copy-pop";
const status = document.createElement("p");
status.className = "doc-copy-status";
status.setAttribute("aria-hidden", "true"); // visual only — the announcer is the role=status node
status.textContent = ok ? "Path copied." : "Path ready — copy it from the field.";
let field = null;
if (ok) {
block.appendChild(status);
} else {
field = makePathFallbackField(path);
block.append(field, status);
}
/* Anchor under the button: the block floats in the header
* (sticky = positioned), so it never wraps the meta row or
* grows the bar (the phase-12/34 contract). */
const header = document.querySelector("header.doc-header");
if (header) {
const br = btn.getBoundingClientRect();
const hr = header.getBoundingClientRect();
block.style.top = `${br.bottom - hr.top + 6}px`;
block.style.left = `${br.left - hr.left}px`;
header.appendChild(block);
}
const announcer = document.querySelector("#doc-copy-announcer");
if (announcer) announcer.textContent = status.textContent;
if (ok) {
clearTimeout(copyStatusTimer);
copyStatusTimer = setTimeout(() => {
if (block.isConnected) block.remove();
}, 4000); // auto-clear ~4s (the toast idiom)
} else if (field) {
field.focus({ preventScroll: true }); // selects the path — ready to copy
}
return ok;
}
/* renderDocument fills the page elements; the page additionally owns
* the document.title (the modal keeps the page title untouched). */
function render(doc) {
renderDocument(doc, { titleEl, metaEl, contentEl });
/* Phase 153 (task 02): wire the shared core's "Copy path"
* control with the page-local helper — the copied string is the
* same two values the meta row renders. */
const copyBtn = metaEl.querySelector(".doc-copy-path");
if (copyBtn) {
copyBtn.addEventListener(
"click",
() => void copyPathWithFallback(copyBtn, doc.source + "/" + doc.path),
);
}
document.title = `${doc.title} · ${brand()}`;
}