for the prose set (phase 165 —
* isProseDoc),
otherwise. The XSS contract:
* innerHTML only through renderMarkdown; every document-derived
* string (summary text included) is a text node. */
export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
titleEl.textContent = doc.title;
// Phase 34 task 04: the titlebar title ellipsizes — the full title
// stays reachable on hover via the `title` attribute.
titleEl.setAttribute("title", doc.title);
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
// URL — the task-01 resolver's output; ABSENT on the wire for
// non-git documents, so their meta row stays byte-identical to
// pre-phase). It lands AFTER the path code and BEFORE the Created
// badge — 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 originItems = doc.origin_url ? [originAnchor(doc)] : [];
metaEl.replaceChildren(
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
// clicked document). textContent = the locale date (fmtDate — the
// Indexed idiom), and the badge's title = the FULL ISO timestamp
// (the titleEl ellipsis-precision idiom — the row clips, the exact
// value stays reachable).
metaBadge("doc-created", `Created ${fmtDate(doc.created_at)}`, doc.created_at),
metaBadge("doc-indexed", `Indexed ${fmtDate(doc.indexed_at)}`),
metaBadge("doc-chunks", `${doc.chunks} chunk${doc.chunks === 1 ? "" : "s"}`),
);
// Phase 106 (task 09, D7): the ADMIN-ONLY date editor — the
// module-cached whoami promise (docAdminReady — the SAME single
// request per page the summary edit shares) gates the wiring, so a
// non-admin / token holder / failed whoami keeps EXACTLY the task-08
// badge row: no button, no wiring, no admin-only network call (the
// phase-57 split). The button lands after the Created badge (task
// 08's insertion point); the editor itself lives in wireDateEdit.
void docAdminReady().then((admin) => {
if (admin) wireDateEdit(metaEl, doc);
});
contentEl.replaceChildren();
// Phase 148 (task 03): the page strip — the payload's page_urls
// (task 02's endpoint: the exact 1-based route list for a
// page-qualifying PDF; ABSENT on the wire for every other
// document) is the ONE branch that replaces the content slot (phase
// 163: the PRIMARY class only — an "additive" doc falls through to
// the text rendering with the strip behind a toggle, D4/A1): the
// markdown/.doc-prose/.doc-raw content block AND the labeled Summary panel
// (including its admin edit wiring — moot without the panel) are
// NOT built. The per-page descriptions stay in the DB for Q&A (the
// importer's content/embeddings/summary are untouched); they are
// simply not shown to the user (the owner's contract). The meta
// badge row above renders exactly as pre-phase. null / absent /
// empty page_urls falls through to the EXACT pre-phase rendering
// below (byte-identical for every other doc — the load-bearing
// property).
const pageUrls = Array.isArray(doc.page_urls) ? doc.page_urls : null;
const stripEligible = pageUrls !== null && pageUrls.length > 0;
// Phase 163 (task 04, LOCKED A2): the strip's CLASS picks the view.
// Only the exact "additive" string switches on the toggle — absent
// (pre-163 rows, and the omission rule's non-qualifying docs) or
// "primary" read as primary (the tolerant-reading house rule), so
// the fallback class keeps the strip-only DOM byte-identically.
const additive = stripEligible && doc.page_strip_mode === "additive";
if (stripEligible && !additive) {
contentEl.appendChild(docPagesStrip(doc, pageUrls));
return;
}
// Phase 163 (task 04, LOCKED A1/A6): an ADDITIVE doc renders the
// text doc EXACTLY as a non-qualifying one does (Summary panel +
// content below, reused unchanged) INSIDE a .doc-views wrapper —
// the Text/Pages toggle above it flips the wrapper's data-view
// (CSS-driven show/hide, never a re-render); the strip mounts once
// AFTER the text view and stays mounted across flips (lazy imgs,
// display:none while hidden, never fetch). Every non-additive doc
// keeps slot === contentEl — its DOM is byte-identical to pre-phase.
let views = null;
let slot = contentEl;
if (additive) {
views = document.createElement("div");
views.className = "doc-views";
views.setAttribute("data-view", "text"); // LOCKED A6: Text is the default view
contentEl.append(docViewToggle(views), views);
slot = views;
}
// Phase 122 (task 04): an image document — the persistent bytes
// render as the block FIRST; the description (= doc.content)
// follows in the normal content slot below (the plain-content path
// — the last branch in this function).
if (doc.is_image) {
slot.appendChild(docImageBlock(doc));
}
// Phase 36: the summary panel — labeled section ABOVE the original
// content, on BOTH surfaces (page + modal) through this one core.
// Only a non-empty summary renders: markdown docs carry none (phase
// 30) and the fail-soft path leaves summary NULL, so both are
// byte-for-byte unchanged here. Phase 122 (task 04): for an IMAGE
// doc whose summary IS the verbatim description (summary ===
// content — the importer invariant), the panel would duplicate the
// text right below the image, so it is suppressed; an admin-edited
// summary (different text) still renders with the phase-57 edit
// affordance.
const imageSummaryIsContent = doc.is_image && doc.summary === doc.content;
if (doc.summary && doc.summary.trim() !== "" && !imageSummaryIsContent) {
const section = document.createElement("section");
section.className = "doc-summary";
section.setAttribute("aria-label", "Summary");
const title = document.createElement("h2");
title.className = "doc-summary-title";
title.textContent = "Summary";
const body = document.createElement("p");
body.className = "doc-summary-text";
body.textContent = doc.summary; // text node — XSS contract unchanged
section.append(title, body);
slot.appendChild(section);
// Phase 57 (task 02, D4): the section above is the phase-36 shape
// for EVERYONE — only an authenticated admin (docAdminReady(), the
// module-cached whoami promise) then gains the header row + Edit
// button + editor wiring. Anonymous / fetch failure: the panel is
// exactly what phase 36 built (byte-for-byte unchanged).
void docAdminReady().then((admin) => {
if (admin) wireSummaryEdit(section, doc);
});
}
if (doc.format === "md" || doc.format === "markdown") {
const wrap = document.createElement("div");
wrap.className = "doc-md";
wrap.innerHTML = renderMarkdown(doc.content); // escape-first: XSS-safe
slot.appendChild(wrap);
} else if (isProseDoc(doc)) {
// Phase 165 (task 01, LOCKED A2): the prose arm — a TEXT NODE in
// the wrapping .doc-prose block (pre-wrap carries the newlines;
// no markdown parsing of a description). The XSS contract is
// untouched: the content is never parsed as HTML.
const prose = document.createElement("div");
prose.className = "doc-prose";
prose.textContent = doc.content; // text node: never parsed as HTML
slot.appendChild(prose);
} else {
const pre = document.createElement("pre");
pre.className = "doc-raw";
pre.textContent = doc.content; // text node: never parsed as HTML
slot.appendChild(pre);
}
// Phase 163 (task 04): the additive strip mounts once, AFTER the
// text view, inside the wrapper — hidden by [data-view="text"]
// (the lazy imgs never fetch until Pages is chosen; the node
// persists across flips — no refetch).
if (views !== null) {
views.appendChild(docPagesStrip(doc, pageUrls));
}
}
/* ---------- view toggle (phase 163, task 04 — LOCKED A1/A6) ----------
* The Text/Pages toggle of an ADDITIVE strip doc: one
* with two
. Text is pressed initially (A6 — no sparse-ratio
* default flip). A click mutates ONLY the two aria-pressed states and
* the .doc-views wrapper's data-view attribute — the CSS shows/hides
* the text view and the strip (never a re-render, never a refetch).
* The labels are FIXED strings (no doc-derived text inside the
* toggle); createElement + properties/textContent only (the
* never-build-HTML rule — no innerHTML). */
function docViewToggle(viewsEl) {
const group = document.createElement("div");
group.className = "doc-view-toggle";
group.setAttribute("role", "group");
group.setAttribute("aria-label", "Document view");
const textBtn = viewToggleButton("Text");
const pagesBtn = viewToggleButton("Pages");
const select = (view) => {
viewsEl.setAttribute("data-view", view);
textBtn.setAttribute("aria-pressed", view === "text" ? "true" : "false");
pagesBtn.setAttribute("aria-pressed", view === "pages" ? "true" : "false");
};
textBtn.setAttribute("aria-pressed", "true"); // LOCKED A6: Text pressed by default
pagesBtn.setAttribute("aria-pressed", "false");
textBtn.addEventListener("click", () => select("text"));
pagesBtn.addEventListener("click", () => select("pages"));
group.append(textBtn, pagesBtn);
return group;
}
function viewToggleButton(label) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "doc-view-btn";
btn.textContent = label; // fixed string — never doc-derived
return btn;
}
/* ---------- image block (phase 122, task 04) ----------
* The viewer's image block for an ``is_image`` document: the
* document's PERSISTENT bytes (doc.image_url — the
* /api/documents/{id}/image route) as a block (max-width 100%;
* the theme's surface treatment lives in .doc-image). alt = the
* summary (the vision description — the WCAG alt contract everywhere);
* a NULL summary (the fail-soft backfill corner) falls back to the
* title. On a load failure (the route's 404 — the row exists but the
* copy was lost) the block shows a small "Image unavailable."
* note (role=status) in its place; the description in the content
* slot below still renders (the doc is still readable). Properties
* only (src, alt) — every document-derived value is a text node /
* property, never innerHTML (the XSS contract, unchanged). */
function docImageBlock(doc) {
const wrap = document.createElement("div");
wrap.className = "doc-image";
const img = document.createElement("img");
// Phase 181 (task 04, design D6/D7): SVG is a VECTOR image doc. A
// drawing carrying only a viewBox has NO intrinsic size, so the
// default replaced-element box (300×150) would sit small inside the
// full-width .doc-image card; the --vector modifier (the base class
// is KEPT so every current rule still applies) lets width: 100%
// span the card — the full-size preview. doc.format is the ONLY
// place in the app that knows the type: the bytes URL is
// extension-less and SourceRef carries no format field by design
// (D7). LOCKED A3: the SVG is NEVER inlined into the DOM —
// img.src = doc.image_url stays createElement + property
// assignment (this file's XSS contract, unchanged). Legacy
// honesty (D6): a row still indexed as TEXT (an .svg under
// BOR_IMAGES=0, or not yet re-synced) keeps rendering its XML in
// .doc-raw — the truth about that row (there are no bytes behind
// it); task 02's kind-mismatch guard converts it on the next sync.
// Nobody "fixes" that by sniffing the content string.
img.className =
doc.format === "svg"
? "doc-image-img doc-image-img--vector"
: "doc-image-img";
img.src = doc.image_url;
img.alt =
typeof doc.summary === "string" && doc.summary.trim() !== ""
? doc.summary
: doc.title;
img.addEventListener("error", () => {
const note = document.createElement("p");
note.className = "doc-image-unavailable";
note.setAttribute("role", "status");
note.textContent = "Image unavailable — the file is missing.";
wrap.replaceChildren(note);
});
wrap.appendChild(img);
return wrap;
}
/* ---------- page strip (phase 148, task 03) ----------
* The viewer's page strip for a page-qualifying PDF (doc.page_urls
* non-empty — the task-02 payload: one
* /api/documents/{id}/pages/{n} route per rendered page, 1-based).
* A .doc-pages container goes into the content slot INSTEAD of the
* text content and the Summary panel (the renderDocument branch
* above) — the owner's contract: the per-page descriptions stay in
* the DB for Q&A, they are just not displayed. One
* per URL: a lazy (alt =
* "Page N of " — the WCAG alt contract, the title as the
* fallback context, the phase-122 image idiom) + a "Page N"
* . A page whose copy was lost
* (the route's defensive 404) swaps ONLY ITS figure's for a
* small "Page N unavailable." note (role=status — the phase-122
* image-unavailable note class, the same muted styling); the rest of
* the strip is untouched (one lost copy never hides the other pages).
* createElement + properties/textContent only — every document-
* derived string (the title in the alt, the note) lands via a
* property or textContent, never innerHTML (the XSS contract,
* unchanged). */
function docPagesStrip(doc, pageUrls) {
const wrap = document.createElement("div");
wrap.className = "doc-pages";
pageUrls.forEach((url, i) => {
const n = i + 1; // 1-based over page_urls (the route list's own numbering)
const figure = document.createElement("figure");
figure.className = "doc-page";
const img = document.createElement("img");
img.loading = "lazy"; // lazy imgs — a long manual streams down the strip
img.src = url;
img.alt = `Page ${n} of ${doc.title}`;
img.addEventListener("error", () => {
// The phase-122 idiom (its muted note class, reused): the lost
// copy's becomes a small "Page N unavailable." note in
// ITS figure only — the caption and every other page stand.
const note = document.createElement("p");
note.className = "doc-image-unavailable";
note.setAttribute("role", "status");
note.textContent = `Page ${n} unavailable.`;
img.replaceWith(note);
});
const figcaption = document.createElement("figcaption");
figcaption.className = "doc-page-num";
figcaption.textContent = `Page ${n}`;
figure.append(img, figcaption);
wrap.appendChild(figure);
});
return wrap;
}
/* ---------- summary editing (phase 57, task 02 — D4, admin-only) ----------
* The .doc-summary panel is the ONE place the stored summary is edited
* (page + modal through this core). Only an admin (docAdminReady) ever
* gets the affordance; the public viewer is byte-for-byte unchanged. */
/* The admin gate (D4 — the viewer stays public): the module-cached
* /api/whoami promise (header.js's fetchIsAdmin — the SAME single
* request per page the shared header already makes on every surface,
* so this adds no request of its own). A non-admin or any fetch
* failure resolves false → the anonymous viewer. */
async function docAdminReady() {
try {
return (await fetchIsAdmin()) === true;
} catch {
return false;
}
}
/* The edit affordance on one rendered .doc-summary section. The bare
* h2 becomes a header row (label left, Edit button right). Edit swaps
* the .doc-summary-text node for the inline editor — a prefilled
* textarea (value, never innerHTML — XSS contract), Save / Cancel,
* and a role=status live region. Save PATCHes /api/documents/summary
* with { source, path, summary } — the pair comes from the doc object
* (the same values the modal core carries, document-modal.js). Success
* re-renders the text node via textContent and announces "Summary
* updated."; an empty save that clears announces "Summary cleared."
* and removes the panel a short beat later — the confirmation stays
* readable, and the renderer only draws the panel for non-empty
* summaries. Cancel restores the text node. A failure keeps the
* editor open with the user's text and shows neutral retry copy
* (phase-55 convention). */
function wireSummaryEdit(section, doc) {
const title = section.querySelector(".doc-summary-title");
const body = section.querySelector(".doc-summary-text");
if (!title || !body) return;
/* Header row: label left, Edit button right (admin-only — the
* anonymous section keeps its bare h2). */
const head = document.createElement("div");
head.className = "doc-summary-head";
const editBtn = document.createElement("button");
editBtn.type = "button";
editBtn.className = "doc-summary-edit";
editBtn.textContent = "Edit";
head.append(title, editBtn);
section.replaceChildren(head, body);
/* Editor parts (built once; the textarea is rebuilt on every open so
* it always starts from the CURRENT stored summary). */
let editor = null;
const actions = document.createElement("div");
actions.className = "doc-summary-actions";
const saveBtn = document.createElement("button");
saveBtn.type = "button";
saveBtn.className = "doc-summary-save";
saveBtn.textContent = "Save";
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "doc-summary-cancel";
cancelBtn.textContent = "Cancel";
actions.append(saveBtn, cancelBtn);
const status = document.createElement("p");
status.className = "doc-summary-status";
status.setAttribute("role", "status");
status.setAttribute("aria-live", "polite");
/* Back to the display state: the text node re-rendered from the
* doc object (the CURRENT stored summary), the live region (the
* announced message), the Edit button available again. If the
* summary is gone (a clear landed while the editor was open — e.g.
* Cancel right after a successful empty save) the panel is gone
* too: the renderer only draws it for non-empty summaries. */
function closeEditor(message) {
status.textContent = message;
editBtn.hidden = false;
if (typeof doc.summary !== "string" || doc.summary.trim() === "") {
section.remove();
return;
}
body.textContent = doc.summary; // text node — the CURRENT stored summary
section.replaceChildren(head, body, status);
editBtn.focus();
}
async function saveSummary() {
const value = editor.value;
saveBtn.disabled = true; // one PATCH at a time (never stale)
status.textContent = "";
try {
const res = await fetch("/api/documents/summary", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source: doc.source, path: doc.path, summary: value }),
});
if (!res.ok) {
// Neutral retry copy (phase-55 convention) — the user's text
// stays in the editor (the editor stays open on failure).
status.textContent = "Couldn't update the summary — try again.";
return;
}
const data = await res.json();
if (data.summary === null) {
// Cleared (D4): the panel disappears — the renderer only draws
// it for non-empty summaries. The live-region confirmation
// stays visible for a short beat before the panel leaves the
// DOM (a screen reader must be able to read it; the removal
// is a no-op if the surface re-rendered or closed meanwhile).
doc.summary = null;
status.textContent = "Summary cleared.";
setTimeout(() => section.remove(), 2000);
return;
}
doc.summary = data.summary;
closeEditor("Summary updated.");
} catch {
// Network failure: same neutral shape, the reachable? copy.
status.textContent = "Couldn't update the summary — is the app reachable?";
} finally {
saveBtn.disabled = false;
}
}
function openEditor() {
editor = document.createElement("textarea");
editor.className = "doc-summary-editor";
editor.value = typeof doc.summary === "string" ? doc.summary : ""; // value, never innerHTML
status.textContent = "";
editBtn.hidden = true;
section.replaceChildren(head, editor, actions, status);
editor.focus();
}
editBtn.addEventListener("click", openEditor);
saveBtn.addEventListener("click", () => {
void saveSummary();
});
cancelBtn.addEventListener("click", () => closeEditor(""));
}
/* ---------- date editing (phase 106, task 09 — D7, admin-only) ----------
* The .doc-meta badge row (task 08's Created badge) is the ONE place
* the stored creation date is edited (page + modal through the
* shared renderDocument core — no per-surface copy). Only an admin
* (docAdminReady) ever gets the affordance; a non-admin / token
* holder / failed whoami keeps exactly the task-08 badge row (no
* button, no wiring, no admin-only network call — the phase-57
* split). Save PATCHes /api/documents/date with { source, path,
* date } — a non-empty date sets it (the server normalizes it, D3,
* and stores the manual flag, D1); the explicit "Revert to sync"
* affordance sends date: null (the D7 CLEAR — the manual flag drops
* and the stored date stands until the next sync refreshes it).
* The badge re-renders from the RESPONSE's created_at — the UI shows
* exactly what the server stored, never the input's optimistic
* value. §7.4 never-stale: the controls disable IMMEDIATELY on
* Save/Revert (no double-submit); a failure (non-2xx or network)
* lands the server detail (or the canned retry copy) in the
* role=alert line, reverts the input to the stored date, and
* re-enables — the UI never claims a state the server didn't save.
* An EMPTY date input can't clear: Save disables itself on an empty
* input (the explicit Revert is the only clear path — no accidental
* wipes). */
/* The server detail of a non-2xx response (the git-sources.js
* apiDetail shape — the phase-89 error-line idiom: a string detail
* or the 422 array's first msg), with the neutral fallback for a
* non-JSON body. */
async function apiDetail(res, fallback) {
try {
const data = await res.json();
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
return String(data.detail[0].msg);
}
if (typeof data.detail === "string" && data.detail) return data.detail;
} catch {
/* non-JSON error body */
}
return fallback;
}
/* The date editor on one rendered .doc-meta badge row: the "Edit
* date" text button after the Created badge (admin-only — the public
* row keeps task 08's shape). Edit swaps the button for the inline
* editor in place — a native (value, never
* innerHTML — XSS contract) prefilled with the stored date's UTC
* date part, Save / Cancel, the muted "Revert to sync" clear, and
* the role=status / role=alert live lines. One PATCH at a time (the
* controls disable before the fetch and re-enable in the finally —
* every outcome, never stale, PLAN §7.4). */
function wireDateEdit(metaEl, doc) {
const createdBadge = metaEl.querySelector(".doc-created");
if (!createdBadge) return;
/* Edit affordance (admin-only — the public badge row keeps exactly
* task 08's shape: no button, no wiring). setAttribute, never
* innerHTML (the document-derived pair is user-storable). */
const editBtn = document.createElement("button");
editBtn.type = "button";
editBtn.className = "doc-date-edit";
editBtn.textContent = "Edit date";
editBtn.setAttribute(
"aria-label",
`Edit creation date: ${doc.source}/${doc.path}`,
);
createdBadge.insertAdjacentElement("afterend", editBtn);
/* Editor parts (built once; the input is rebuilt on every open so
* it always starts from the CURRENT stored date). */
let input = null;
const box = document.createElement("span");
box.className = "doc-date-editor";
const saveBtn = document.createElement("button");
saveBtn.type = "button";
saveBtn.className = "doc-date-save";
saveBtn.textContent = "Save";
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "doc-date-cancel";
cancelBtn.textContent = "Cancel";
const revertBtn = document.createElement("button");
revertBtn.type = "button";
revertBtn.className = "doc-date-revert";
revertBtn.textContent = "Revert to sync";
const status = document.createElement("span");
status.className = "doc-date-status";
status.setAttribute("role", "status");
status.setAttribute("aria-live", "polite");
const errorLine = document.createElement("span");
errorLine.className = "doc-date-error";
errorLine.setAttribute("role", "alert");
errorLine.setAttribute("aria-live", "assertive");
/* The stored date as a native date-input value: the UTC date part
* of the stored ISO timestamp (D3 stores UTC timestamptz). */
function storedValue() {
return new Date(doc.created_at).toISOString().slice(0, 10);
}
/* One PATCH at a time (PLAN §7.4): the controls disable
* IMMEDIATELY on submit; Save ADDITIONALLY disables on an EMPTY
* input (the explicit Revert is the only clear path — no
* accidental wipes). */
function setControlsLocked(locked) {
if (input) input.disabled = locked;
saveBtn.disabled = locked || input.value === "";
cancelBtn.disabled = locked;
revertBtn.disabled = locked;
}
/* Back to the display state: the badge row + the Edit button (the
* task-08 shape restored) and focus back on the opener. Idempotent
* (a stale success-beat timer may call it after a Cancel — the
* re-insert is a same-position move and the box is already out).
* The last announcement stays readable a short beat because the
* caller defers the call (the phase-57 "confirmation stays
* readable" precedent). */
function closeEditor() {
editBtn.hidden = false;
createdBadge.insertAdjacentElement("afterend", editBtn);
box.remove();
editBtn.focus();
}
async function saveDate(dateValue) {
setControlsLocked(true); // one PATCH at a time (never stale)
errorLine.textContent = "";
try {
const r = await fetch("/api/documents/date", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
source: doc.source,
path: doc.path,
date: dateValue,
}),
});
if (!r.ok) {
// The server detail into the role=alert line (the canned
// retry copy on a non-JSON body); the input reverts to the
// stored date; the controls re-enable in the finally. The
// editor stays open — the user's attempt is visible.
errorLine.textContent = await apiDetail(
r,
"Couldn't save the date — try again.",
);
input.value = storedValue();
return;
}
const res = await r.json();
// Response-driven: the badge shows exactly what the server
// stored (never the input's optimistic value), and the doc
// object syncs so a re-open prefills the CURRENT date.
doc.created_at = res.created_at;
doc.created_at_manual = res.created_at_manual;
createdBadge.textContent = `Created ${fmtDate(res.created_at)}`;
createdBadge.setAttribute("title", res.created_at);
// The confirmation lands AFTER the badge update (the phase-89
// last-announce order), then the row collapses back to the
// task-08 shape a short beat later (the announcement stays
// readable — the status line leaves with the box).
status.textContent =
dateValue === null
? "Reverted to sync-managed date."
: `Date saved for ${doc.source}/${doc.path}.`;
setTimeout(closeEditor, 2000);
} catch {
// Network failure: the canned retry copy (the phase-55
// neutral shape), the input reverts to the stored date, the
// editor stays open.
errorLine.textContent = "Couldn't save the date — try again.";
input.value = storedValue();
} finally {
setControlsLocked(false);
}
}
function openEditor() {
input = document.createElement("input");
input.type = "date";
input.className = "doc-date-input";
input.setAttribute("aria-label", "Document creation date");
input.value = storedValue(); // value, never innerHTML
input.addEventListener("input", () => {
// An EMPTY input can't clear: Save disables (the explicit
// Revert below is the only clear path — no accidental wipes).
// The locked state owns the controls while a PATCH is in
// flight (the input is disabled then, so this can't race it).
if (!input.disabled) saveBtn.disabled = input.value === "";
});
status.textContent = "";
errorLine.textContent = "";
box.replaceChildren(input, saveBtn, cancelBtn, revertBtn, status, errorLine);
editBtn.hidden = true;
createdBadge.insertAdjacentElement("afterend", box);
setControlsLocked(false);
input.focus();
}
editBtn.addEventListener("click", openEditor);
saveBtn.addEventListener("click", () => {
// Save is only ever enabled with a NON-empty input (the empty
// state disables it) — the clear path is the explicit Revert.
void saveDate(input.value);
});
cancelBtn.addEventListener("click", () => {
// Cancel: back to the display state, no PATCH (the stored value
// is untouched — the badge was never mutated).
status.textContent = "";
errorLine.textContent = "";
closeEditor();
});
revertBtn.addEventListener("click", () => {
void saveDate(null); // the D7 CLEAR: { source, path, date: null }
});
}
/* ---------- /document.html page (phases 10/13/19) ----------
* Phase 26: viewer-page-specific — see the import-safety note in the
* header. The guard is #doc-title: it exists only on this page, so the
* modal module's `import { renderDocument } from "./document.js"` on
* the chat/sources pages evaluates none of the code below. */
if (document.querySelector("#doc-title")) {
const params = new URLSearchParams(window.location.search);
const source = params.get("source") || "";
const path = params.get("path") || "";
const titleEl = document.querySelector("#doc-title");
const metaEl = document.querySelector("#doc-meta");
const contentEl = document.querySelector("#doc-content");
const notFoundEl = document.querySelector("#doc-not-found");
const mainEl = document.querySelector("#main");
const backLink = document.querySelector("#doc-back");
/* Back button (phase 13): the return target comes from the `back`
* query param, not the browser history — the dedicated page is
* reachable directly (no history to go back to). The param is honored
* only for same-origin relative URLs (starts with "/" but not "//"),
* so absolute (https://…), protocol-relative (//…), and
* pseudo-protocol (javascript:…) values are rejected; anything else
* falls back to the Sources page. The static href="/sources.html" in
* document.html remains the no-JS fallback, and with the href set the
* anchor's default click behavior IS the deterministic navigation (no
* browser-history heuristics). */
const backParam = params.get("back") || "";
const backTarget =
backParam.startsWith("/") && !backParam.startsWith("//")
? backParam
: "/sources.html";
backLink.href = backTarget;
const backLabel = backLink.querySelector("span");
if (backLabel) {
backLabel.textContent =
backTarget === "/"
? "Chat"
: backTarget === "/sources.html"
? "Sources"
: "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()}`;
}
function showNotFound() {
titleEl.textContent = "Document not found";
titleEl.removeAttribute("title"); // no stale full-title tooltip
document.title = `Document not found · ${brand()}`;
metaEl.replaceChildren();
contentEl.replaceChildren();
notFoundEl.hidden = false;
}
/* Phase 19 (phase 34 task 03, owner confirmation 2026-08-26): the
* viewer now carries the SAME standard bar as every other page —
* nav incl. the admin-only links, the auth pair — toggled on the
* module's cached whoami (the single whoami per page; fetchIsAdmin
* is imported for docAdminReady's parity — the same cached promise
* either way).
*
* Phase 79 (task 05): the gate settles the auth FIRST — silent
* re-auth of a cached token, then the role check (on the shared
* cached whoami) — and the EXISTING boot sequence runs only on the
* SETTLED role: onAuthed (the content load) for a signed-in role
* ONLY (anonymous never fetches the content — the inline gate is
* the surface, no not-found card for an auth failure; #main is
* inert while the gate is visible — WCAG, the inert-pair contract),
* and the shared header boots in the .then AFTER the gate settles
* for EVERY role (the gate locks #main, not the header — the
* anonymous contract is byte-identical to the shell: Sign in
* offered, admin links ship hidden, the steering panel removed).
* Awaiting the gate first is what makes the header race-free: a
* silent re-auth lands before the first whoami fires, so the header
* reads the post-auth role exactly once (no stale anonymous bar for
* a returning token user, no second whoami). An admin (or a
* validly cached token user) gets onAuthed immediately — the gate
* (which ships hidden + inert) never shows. The admin-only edit
* affordance (docAdminReady() inside renderDocument) stays
* admin-only — it runs only in the post-auth render path. */
mountGate(document.getElementById("main"), () => {
load();
}).then(() => {
void initSharedHeader(); // every role — on the SETTLED whoami
});
/* The New chat binding is module-owned (assets/header.js, phase 34
* task 02 — the SINGLE binding): on this non-chat page it clears the
* phase-14 conversation key and navigates to the chat's empty state. */
async function load() {
try {
if (!source || !path) {
showNotFound();
return;
}
const r = await fetch(contentUrl(source, path));
if (!r.ok) {
showNotFound();
return;
}
render(await r.json());
} catch {
showNotFound();
} finally {
mainEl.focus(); // move focus to main on load (a11y)
}
}
}