/* 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
, * pre-wrap: it WRAPS at the reading column; * • any other → the raw content as a text node inside *
 (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 ` 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  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  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  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: ` 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 
per URL — a lazy (alt = "Page N of ", 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"); img.className = "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) } } }