phase: 153_document_viewer_context
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:
+12
-9
@@ -439,10 +439,13 @@ function scrollReveal(wrap, behavior = SCROLL) {
|
||||
* The href a source chip carries: the dedicated viewer (no-JS /
|
||||
* context-menu escape hatch). Phase 26: the chip's left-click is
|
||||
* intercepted and the document opens in the same-page modal instead
|
||||
* (document-modal.js) — this URL is also what the modal's "Full page"
|
||||
* link points at. All query values are percent-encoded: real paths
|
||||
* contain slashes and sometimes spaces, which would otherwise corrupt
|
||||
* the query string. `back` tells the viewer which page to return to
|
||||
* (document-modal.js) — phase 153 (task 01) made the chip's click
|
||||
* pass THIS SAME `back` to openDocumentModal, so this URL is also
|
||||
* what the modal's "Full page" link points at (the chat path:
|
||||
* `&back=%2F` rides along; Sources links omit it on both sides, so
|
||||
* their URLs stay byte-identical to pre-phase). All query values
|
||||
* are percent-encoded: real paths contain slashes and sometimes
|
||||
* spaces, which would otherwise corrupt the query string. `back` tells the viewer which page to return to
|
||||
* when its back button is clicked — the chips live in the chat, so chat
|
||||
* passes "/" (the viewer validates it: only same-origin relative URLs
|
||||
* are honored; Sources links omit it and get the viewer's /sources.html
|
||||
@@ -1697,7 +1700,7 @@ function appendSources(wrap, sources) {
|
||||
chip.addEventListener("click", (e) => {
|
||||
e.preventDefault(); // no new tab (phase 26) — the modal takes over
|
||||
e.stopPropagation();
|
||||
openDocumentModal(s.source, s.path, chip);
|
||||
openDocumentModal(s.source, s.path, chip, "/"); // back → the chat page (phase 153)
|
||||
});
|
||||
chip.textContent = label;
|
||||
chip.title = label;
|
||||
@@ -1759,7 +1762,7 @@ function appendRelated(wrap, related) {
|
||||
link.addEventListener("click", (e) => {
|
||||
e.preventDefault(); // no new tab (phase 26) — the modal takes over
|
||||
e.stopPropagation();
|
||||
openDocumentModal(s.source, s.path, link);
|
||||
openDocumentModal(s.source, s.path, link, "/"); // back → the chat page (phase 153)
|
||||
});
|
||||
link.textContent = docLabel;
|
||||
link.title = docLabel; // full path as the native tooltip (chip pattern)
|
||||
@@ -1801,7 +1804,7 @@ function appendSourceImageFigure(meta, s) {
|
||||
fig.addEventListener("click", (e) => {
|
||||
e.preventDefault(); // no new tab (phase 26) — the modal takes over
|
||||
e.stopPropagation();
|
||||
openDocumentModal(s.source, s.path, fig);
|
||||
openDocumentModal(s.source, s.path, fig, "/"); // back → the chat page (phase 153)
|
||||
});
|
||||
const img = document.createElement("img");
|
||||
img.className = "source-image-img";
|
||||
@@ -1822,7 +1825,7 @@ function appendSourceImageFigure(meta, s) {
|
||||
chip.addEventListener("click", (e) => {
|
||||
e.preventDefault(); // no new tab (phase 26) — the modal takes over
|
||||
e.stopPropagation();
|
||||
openDocumentModal(s.source, s.path, chip);
|
||||
openDocumentModal(s.source, s.path, chip, "/"); // back → the chat page (phase 153)
|
||||
});
|
||||
chip.textContent = label;
|
||||
chip.title = label;
|
||||
@@ -1905,7 +1908,7 @@ function appendAnswerImage(wrap, ref) {
|
||||
fig.addEventListener("click", (e) => {
|
||||
e.preventDefault(); // no new tab (phase 26) — the modal takes over
|
||||
e.stopPropagation();
|
||||
openDocumentModal(ref.source, ref.path, fig);
|
||||
openDocumentModal(ref.source, ref.path, fig, "/"); // back → the chat page (phase 153)
|
||||
});
|
||||
const img = document.createElement("img");
|
||||
img.className = "answer-image-img";
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -4461,6 +4461,95 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
text-decoration: none;
|
||||
}
|
||||
.doc-origin:hover, .doc-origin:focus-visible { text-decoration: underline; }
|
||||
|
||||
/* Phase 153 (task 02): the meta row's "Copy path" control — the
|
||||
row-action language (icon + label, the .git-source-remove idiom)
|
||||
in a NEUTRAL secondary fill (the .git-source-ignore hover pair —
|
||||
brand-soft, 12.4:1 on --surface; the meta row's text sits on the
|
||||
titlebar's --surface). Compact on desktop (the meta row's 0.78rem
|
||||
scale — a 44px floor would grow the bar); the ≤640px block gives
|
||||
it the ≥44px touch-target floor (PLAN §7.1). The VISIBLE "Copy
|
||||
path" text is the accessible name (no aria-label); the icon is
|
||||
aria-hidden. */
|
||||
.doc-copy-path {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.12rem 0.5rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--ink-soft); /* 5.1:1 on --surface (the meta row's pair) */
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.78rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.doc-copy-path svg { width: 13px; height: 13px; display: block; flex: 0 0 auto; }
|
||||
.doc-copy-path:hover { background: var(--brand-soft); color: var(--brand-ink); }
|
||||
.doc-copy-path:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; }
|
||||
|
||||
/* Phase 153 (task 02): the Copy-path status line — the VISIBLE
|
||||
confirmation (aria-hidden — the surface's role=status live region
|
||||
is the a11y announcer, the share pattern): the meta row's text
|
||||
scale, var(--ink-soft) on --surface (5.1:1 — the pair recorded at
|
||||
:root). Serves BOTH surfaces: the page's floating block and the
|
||||
modal's wrapping meta block. */
|
||||
.doc-copy-status {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--ink-soft); /* 5.1:1 on --surface */
|
||||
}
|
||||
|
||||
/* Phase 153 (task 02): the /document.html page's Copy-path state
|
||||
block — a floating panel anchored under the control (absolutely
|
||||
positioned in the header — the meta row must never wrap or grow
|
||||
the bar, the phase-12/34 contract, so the state floats OUT of
|
||||
the row). Surface fill, --line border, the standard shadow;
|
||||
wraps (the field + the status may share a row or take separate
|
||||
rows at narrow widths). z-index 30 sits inside the header's own
|
||||
z-20 root context, so the block floats above the page content. */
|
||||
.doc-copy-pop {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
max-width: min(36rem, calc(100vw - 2rem));
|
||||
padding: 0.45rem 0.6rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* Phase 153 (task 02): the Copy-path inline fallback field — the
|
||||
.share-link-fallback pattern (input variant): a non-secure (http)
|
||||
origin rejects the clipboard, so the full path is offered as an
|
||||
input-like <input> that selects itself on focus — click or Tab,
|
||||
then Ctrl/Cmd+C. readOnly (a copy surface, not an editor), mono
|
||||
(the path is data — the .share-link-fallback font), surface
|
||||
fill, --line border; truncates with an ellipsis at narrow widths
|
||||
(the selection is the copy). Ink on surface ≈13.8:1 (the
|
||||
.share-link-fallback pair). */
|
||||
.doc-path-fallback {
|
||||
max-width: 26rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.78rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.doc-path-fallback:hover { border-color: var(--brand); }
|
||||
.doc-path-fallback:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; }
|
||||
|
||||
.doc-chunks { font-family: var(--mono); }
|
||||
/* Phase 106 (task 08, D8): the Created badge — the SAME family as the
|
||||
Indexed one (no own look: the meta row's text, var(--ink-soft) on
|
||||
@@ -5552,6 +5641,7 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
#doc-title { font-size: 1.1rem; }
|
||||
.doc-path { max-width: 16rem; }
|
||||
.doc-origin { max-width: 16rem; } /* phase 145 (task 03): the Original anchor squeezes like the path */
|
||||
.doc-copy-path { min-height: 44px; min-width: 44px; } /* phase 153 (task 02): the Copy path control's ≥44px touch-target floor (PLAN §7.1) */
|
||||
.doc-md { padding: 1.1rem 1rem; }
|
||||
.doc-summary { padding: 0.75rem 0.9rem; }
|
||||
.doc-raw { padding: 1rem; font-size: 0.8rem; }
|
||||
|
||||
Reference in New Issue
Block a user