Files
brain-of-reese/frontend/assets/document.js
T
ducoterra 814a400e6d
Build and Push Containers / build-and-push-app (push) Successful in 1m57s
Build and Push Containers / build-and-push-db (push) Successful in 15s
phase: 181_svg_document_preview
**Phase 181 — final verification pass (all tasks already complete; verified + docs pass)**

- Verified: `app/rag/svg_text.py` (100 % cov), vector branch in `_index_image_file`, `_row_kind_mismatch` text→image migration, `IMAGE_MIMES[".svg"]`, sandboxed CSP on `GET /api/documents/{id}/image`, `.doc-image-img--vector` in `document.js`/`styles.css`, figure/thumbnail deliberate non-changes.
- Tests/lint (exact commands): `uv run pytest --cov=app --cov-report=term-missing` → **4265 passed, TOTAL 99 %** (exit 0) · `uv run pytest tests/e2e/test_svg_document_preview.py -v --no-cov` → **5 passed in isolation** · `uv run ruff check .` → clean · `uv run pyright` → **0 errors**.
- Regression (each run in isolation, `--no-cov`, all rc=0): `test_image_documents`, `test_chat_image_questions`, `test_answer_images`, `test_chat_answer_clusters`, `test_image_anchor_context`, `test_image_upload_feedback`, `test_markdown_inline_links_images`, `test_prose_word_wrap`, `test_pdf_support`, `test_pictorial_pdf`, `test_garbage_pdf`, `test_pdf_image_extraction`, `test_pdf_image_context`, `test_pdf_pages_in_sources`, `test_pdf_strip_additive_selfheal`, `test_sync_image_progress`.
- Completion criteria: ①SVG→`is_image`+`image_path`, no vision call, own `<title>/<desc>/<text>` — **met**; ②`image/svg+xml` + `default-src 'none'; sandbox` + `private, max-age=3600`, raster headers byte-unchanged — **met**; ③chat figure → modal `<img>`, no XML — **met**; ④`/document.html` full-width vector in `.doc-image` + text below — **met**; ⑤`![alt](/api/documents/<id>/image)` paints — **met**; ⑥raster/PDF unchanged — **met**; ⑦all gates green — **met**; ⑧commit + phase-dir move — harness-owned.
- Documentation: found and fixed README inconsistencies — stale forward reference "left to phase 181 / is phase 181's job" → completed-tense sizing note; "Updating the documents" standalone-image list missing `svg`; new **Raster only** bullet for question uploads (`.svg` → 422) documenting the `chat_images` guard; missing `svg_indexed=N` / `import: svg indexed` log contract; env-table heading `122/123/124` → `122/123/124/181`; new troubleshooting entry for an `.svg` showing raw XML. Full audit: every `Settings` field and every `.env.example` var is documented; pages/commands/scripts all exist. AGENTS.md verified accurate (phase-181 §5 clause present) — no changes needed beyond what the phase wrote.
- Notable deviation (kept from task 02): `app/api/chat_images.py` subtracts `_VECTOR_IMAGE_EXTENSIONS` from the question-image accept set — a required consequence of LOCKED A7 (`svg` in the default image set) since `_CHAT_IMAGE_FILENAME_RE` refuses to *serve* `.svg`; the regex itself is untouched, as the phase specified.
- Note for the commit: changes include `README.md`, `AGENTS.md` and `.env.example` — the phase file's `git add app/ frontend/ tests/ .env.example .agents/phases/` line omits the two markdown files; stage them too.
- Next pending phase: `182_verbatim_show_text`.
2026-10-14 22:54:43 -04:00

1236 lines
58 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Brain of Reese — document viewer (phase 10) + the shared document
* renderer (phase 26).
*
* Two jobs:
*
* 1. renderDocument(doc, { titleEl, metaEl, contentEl }) — the
* EXPORTED rendering core. The /document.html page and the document
* modal (assets/document-modal.js, phase 26) both render through it,
* so the two surfaces can never drift:
*
* • md / markdown → the shared escape-first renderer (markdown.js)
* in the 72rem centered reading column;
* • prose (phase 165, LOCKED A1 — isProseDoc: an image doc's
* description or a txt/text doc) → the content
* as a text node inside <div class="doc-prose">,
* pre-wrap: it WRAPS at the reading column;
* • any other → the raw content as a text node inside
* <pre class="doc-raw"> (mono, horizontal
* scroll — locked A3: code never wraps);
* • doc.summary non-empty (phase 36 — phase 30 summaries exist
* only on non-markdown docs) → a labeled
* .doc-summary section ABOVE the content;
* null / empty / whitespace renders nothing, so
* markdown docs and fail-soft rows are
* byte-for-byte unchanged.
*
* 2. The /document.html page itself: reads `source`/`path` query
* params, fetches the stateless content endpoint
* (GET /api/documents/content — database only, no filesystem), and
* renders via renderDocument into #doc-title / #doc-meta /
* #doc-content. A missing document (unknown pair, missing params,
* network error) shows the designed not-found card with a link back
* to the Sources page. Phase 79 (task 05): the content endpoint is
* require_user-gated — a direct ANONYMOUS URL meets the inline
* token gate instead (the page document loads; the gated data does
* not): mountGate settles the auth (silent re-auth of a cached
* token, then the shared cached whoami) and the boot sequence
* (initSharedHeader + load) runs as its onAuthed — only a
* signed-in role (admin or token user) ever fetches the content.
*
* XSS-safe by construction: markdown is escaped before transform, raw
* formats are set via textContent, and every document-derived string
* (title, badges, path — and the phase-148 page strip's alt text and
* captions) is written with textContent or a property — never
* innerHTML (innerHTML goes through renderMarkdown, which escapes
* first).
*
* Phase 19: the viewer joins the shared header (assets/header.js) — the
* whoami fetch is the module's cached promise (one request per page,
* shared with initSharedHeader's toggling). Phase 34 task 02: the New
* chat binding is module-owned (assets/header.js, the SINGLE one) — on
* this non-chat page it clears the phase-14 conversation key and
* navigates to the chat's empty state.
*
* Phase 26 (import safety): the modal module imports renderDocument
* from THIS file on the chat/sources pages, so everything
* /document.html-specific runs only when #doc-title exists (the guard
* around the page block below). Importing renderDocument elsewhere has
* no side effects: no back-link resolution, no whoami, no content
* fetch, no New Chat binding.
*
* Phase 36: the renderer owns the optional summary panel — a non-empty
* doc.summary renders as a labeled .doc-summary section above the
* original content on BOTH surfaces (page + modal) through this one
* core; the summary text is a text node (XSS contract unchanged).
*
* Phase 57 (task 02, D4): the panel gains an ADMIN-ONLY edit
* affordance (docAdminReady() gate on the module-cached whoami): an
* Edit button in the panel's header row opens an inline editor
* (prefilled textarea + Save/Cancel + a role=status live region), and
* Save PATCHes /api/documents/summary. The section is built for
* everyone exactly as phase 36 — the public viewer stays
* byte-for-byte identical (no button, no wiring, no admin-only
* network call) until the admin gate resolves true.
*
* Phase 106 (task 08, D8): the .doc-meta badge row gains the Created
* badge BEFORE the Indexed one (the owner's verbatim position — the
* date at the top of a clicked document): `Created <locale date>` via
* fmtDate(doc.created_at), the full ISO timestamp on the badge's
* title (the titleEl ellipsis-precision idiom). ONE shared core —
* the modal and /document.html both render it (no per-surface copy);
* the date arrives in the same content payload (task 05 added
* created_at to GET /api/documents/content). The date EDITOR (the
* admin inline edit + PATCH /api/documents/date) is the task-09
* section below.
*
* Phase 106 (task 09, D7): the badge row gains the ADMIN-ONLY date
* editor (the phase-57 wireSummaryEdit idiom — the same
* docAdminReady() gate on the module-cached whoami promise, so no
* second request per page): an "Edit date" text button after the
* Created badge (task 08's insertion point) swaps in-place for an
* inline editor — a native type=date input + Save / Cancel + the
* muted "Revert to sync" clear affordance (the phase-57 "clear =
* explicit" contrast) + a role=status live line and a role=alert
* error line. Save PATCHes /api/documents/date with { source, path,
* date } and the badge re-renders from the RESPONSE's created_at
* (never the input's optimistic value); "Revert to sync" sends
* date: null (the D7 CLEAR — the manual flag drops, the stored date
* stands until the next sync). An EMPTY date input can't clear (Save
* disables itself — the explicit Revert is the only clear path, no
* accidental wipes). §7.4 never-stale: the controls disable
* immediately on submit (one PATCH at a time); 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.
* A non-admin / token holder / failed whoami keeps the byte-for-byte
* task-08 badge row (no button, no wiring, no admin-only network
* call — the phase-57 split). ONE shared core — the modal and
* /document.html both get it (document-modal.js imports
* renderDocument from this module — no per-surface copy).
*
* Phase 122 (task 04): image documents — when doc.is_image is true,
* #doc-content renders the persistent bytes FIRST (the <img> block
* from doc.image_url — the /api/documents/{id}/image route — alt =
* the summary, the WCAG alt contract; a NULL summary falls back to
* the title), and the description (doc.content — the ONLY readable
* text of the doc) follows in the EXISTING plain-content slot below
* (phase 165: the .doc-prose path — a description IS prose, so it
* wraps; it was never markdown, and never belonged in the pre). The
* labeled Summary panel is suppressed for the verbatim-description
* case (summary === content — the importer invariant; the panel
* would duplicate the text right below the image); an admin-edited
* summary (different text) still renders in its panel with the
* phase-57 edit affordance. An <img> load failure (the route's 404 —
* the row exists but the copy was lost) swaps the block for a small
* "Image unavailable" note (role=status): the page still shows the
* description. ONE shared core — page + modal both get it.
*
* Phase 145 (task 03): the meta row's "Original" anchor — when the
* content payload carries origin_url (a git-sourced document — the
* task-01 resolver's sanitized repository URL; ABSENT on the wire
* for non-git documents, whose meta row stays byte-identical to
* pre-phase), an <a class="doc-origin"> joins the .doc-meta row
* AFTER the path code and BEFORE the Created badge: href + visible
* text = the full URL VERBATIM (the copyable-link contract — no
* decorative label inside the anchor text; the .doc-origin ellipsis
* clips the display), target=_blank + rel=noopener (the modal's
* "Full page" escape-hatch idiom), and the title carries the full
* URL plus ` — branch: <name>` when the payload pins a branch (the
* pathCode ellipsis-precision idiom). ONE shared core — the modal
* AND /document.html render it (no per-surface copy); createElement
* + properties/textContent only (the never-build-HTML rule — no
* innerHTML).
*
* Phase 148 (task 03): the page strip — when the payload carries
* page_urls (a page-qualifying PDF — the phase-133 fallback class
* or the phase-140 pictorial class — the task-02 endpoint's exact
* 1-based /api/documents/{id}/pages/{n} route list; ABSENT on the
* wire for every other document, so they render the EXACT pre-phase
* DOM on BOTH surfaces — the load-bearing byte-identity property),
* #doc-content renders a .doc-pages strip INSTEAD of the text
* content AND the .doc-summary panel FOR THE PRIMARY CLASS ONLY
* (phase 163 — the absent/`primary` mode; see the next paragraph):
* one <figure class="doc-page"> per URL — a lazy <img> (alt = "Page N of <title>", the WCAG alt
* contract — the title as the fallback context) + a "Page N"
* <figcaption> — while the meta badge row renders exactly as today.
* The owner's contract: the per-page descriptions stay in the DB for
* Q&A (the content/embeddings/summaries are untouched); they are
* simply not shown to the user — so the Summary panel is NOT built
* (its admin edit affordance is moot without it). A page whose copy
* was lost (the route's defensive 404s) swaps ONLY ITS figure's
* <img> for a "Page N unavailable." note (role=status — the
* phase-122 image-unavailable idiom, the same muted class); the rest
* of the strip is untouched (one lost copy never hides the other
* 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 163 (task 04, LOCKED A1/A2/A6): the strip's CLASS decides the
* view. page_strip_mode === "additive" (the pictorial class — a USABLE
* text layer, so the pages are a convenience, not the content) keeps
* the TEXT doc EXACTLY as a non-qualifying doc renders it (Summary
* panel + content — the pre-148 branch reused), and the strip sits one
* Text/Pages toggle away: a .doc-view-toggle group (role=group,
* aria-label="Document view", two <button aria-pressed>) above a
* .doc-views wrapper holding the text view + the strip; Text is
* pressed by DEFAULT (A6 — never a sparse-ratio flip), Pages flips the
* wrapper's data-view (CSS shows the strip / hides the text — the
* strip node stays MOUNTED, its lazy imgs never refetch on flip-flop).
* The text is NEVER hidden for this class. The absent/"primary" mode
* (the fallback class — the screenshots ARE the content, and every
* pre-163 stamped row) keeps the strip-only DOM above byte-identically
* (the tolerant read: only the exact "additive" string switches on the
* toggle); falsy page_urls stay untouched. The wrapper exists ONLY on
* additive docs — every other document's DOM is unchanged. Toggle
* labels are fixed strings (no doc-derived text); createElement +
* properties/textContent only.
*
* 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";
import { mountGate } from "./token-gate.js"; // phase 79 (task 05): the inline token gate
// The shared header's control bindings (sign-out / mobile hamburger) —
// EXPLICIT init, once per document (header.js is bundle-inlined per
// entry; import-time side effects would double-bind — 2026-09-08 fix).
bindSharedHeaderControls();
/* Phase 39: the page title's display name — window.BOR_BRAND (set at
* parse time by the classic assets/brand.js, refreshed from
* /api/config). This is a module, so the global is set by the time this
* evaluates; the literal is the no-config fallback only. */
const brand = () => window.BOR_BRAND || "Brain of Reese";
function fmtDate(iso) {
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
function contentUrl(s, p) {
return "/api/documents/content?source=" + encodeURIComponent(s) + "&path=" + encodeURIComponent(p);
}
function metaBadge(cls, text, title) {
const el = document.createElement("span");
el.className = cls;
el.textContent = text;
// Phase 106 (task 08): optional hover precision — the meta row may
// clip (nowrap), so the exact value stays reachable on the badge's
// title (the titleEl ellipsis-precision idiom). Omitted → the
// pre-phase two-argument shape (the other badges are unchanged).
if (title !== undefined) el.setAttribute("title", title);
return el;
}
/* Phase 145 (task 03): the meta row's "Original" anchor — the git
* origin as a copyable external link (called only when the payload
* carries origin_url — a non-git document never gets one, so its
* meta row stays the pre-phase shape). The FULL URL is the anchor's
* visible text (the copyable-link contract — no decorative label
* inside the anchor text: a " (branch)" suffix would break
* copy-paste; the .doc-origin CSS ellipsis clips the display
* instead), target=_blank + rel=noopener (the modal's "Full page"
* escape-hatch idiom), and the title carries the full URL — plus
* ` — branch: <name>` when the payload pins a branch (the pathCode
* ellipsis-precision idiom). Properties + textContent only — never
* innerHTML (the XSS contract). */
function originAnchor(doc) {
const a = document.createElement("a");
a.className = "doc-origin";
a.href = doc.origin_url;
a.target = "_blank";
a.rel = "noopener";
a.textContent = doc.origin_url;
a.setAttribute(
"title",
doc.origin_branch ? doc.origin_url + " — branch: " + doc.origin_branch : doc.origin_url,
);
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;
}
/* ---------- prose set (phase 165, task 01 — LOCKED A1) ----------
* The ONE place the prose rule lives (TODO.md L3: "Need word wrap for
* image explanations and other long text rather than scrolling
* horizontally"). The defect: the description used to land in the
* `pre` and each generated line became one unbreakable line — a
* horizontal scrollbar where every other text surface wraps.
*
* The image arm is UNCONDITIONAL (phase 122 A3: an image doc's
* `content` IS the vision description — prose by construction,
* whatever its extension). The `format` arm covers plain-text docs;
* `doc.format` comes from the server's doc_format() (the lowercased
* suffix). Code and code-ish formats (py, yaml, json, j2, the quadlet
* family, pdf — locked A1/A3: phase 116's scroll contract stands)
* never enter this set. */
const PROSE_FORMATS = new Set(["txt", "text"]);
function isProseDoc(doc) {
return doc.is_image === true || PROSE_FORMATS.has(doc.format);
}
/* ---------- 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 ·
* indexed · chunks — phase 106 (task 08, D8) added created BEFORE
* indexed), and a content container — an optional .doc-summary
* section first (phase 36: only when doc.summary is non-empty —
* markdown docs and fail-soft rows carry none, so they render exactly
* as before), then .doc-md for md/markdown (the shared escape-first
* renderer), <div class="doc-prose"> for the prose set (phase 165 —
* isProseDoc), <pre class="doc-raw"> 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 <img> 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
* <div class="doc-view-toggle" role="group" aria-label="Document
* view"> with two <button type="button" class="doc-view-btn"
* aria-pressed>. 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 <img> (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
* <figure class="doc-page"> per URL: a lazy <img> (alt =
* "Page N of <title>" — the WCAG alt contract, the title as the
* fallback context, the phase-122 image idiom) + a "Page N"
* <figcaption class="doc-page-num">. A page whose copy was lost
* (the route's defensive 404) swaps ONLY ITS figure's <img> 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 <img> 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 <input type="date"> (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)
}
}
}