header.js's control bindings (sign-out, the mobile hamburger, the SINGLE New chat button) ran at module import. The Containerfile stage-1 build inlines header.js into every bundle that imports it (the shell's app.js, token-gate.js and the router's lazy views), so the shell page registered the #nav-toggle click handler twice, and two toggle handlers cancel each other — one tap = open + close = the mobile menu dead in the deployed image only. The dev tree's single ESM instance (and every test that runs against it) never showed it; a lazy view load adding a THIRD copy made the menu work again, which is why the failure looked state-dependent (chat cold boot dead, /sources.html alive). - header.js: the three bindings move into an exported bindSharedHeaderControls(), guarded by a marker on <body> (NOT module state — every bundle copy has its own function instance), so later bundle copies and repeated inits (the token gate's mid-page header re-boot) are no-ops; header.js is now side-effect-free at top level, which also lets esbuild tree-shake the dead copies out of the bundles that do not need them (the token-gate bundle no longer carries the binding code at all) - app.js / login.js / shared.js / document.js: call bindSharedHeaderControls() once at module top — import-time parity, unconditional (no async boot path to miss); doc-edit.js ships no header controls and calls nothing - unit: tests/unit/test_header_bindings_once_per_document.py pins the contract — the init export, the document-level idempotency marker, all three bindings inside the init, NO top-level addEventListener remaining, and exactly one module-top call in each header-carrying page script; stale import-time docstrings in the legacy header pins updated to the new contract Verified: full unit + integration suite (1746 passed), the hamburger / pinned-composer / smoke E2E stories green in isolation, ruff + pyright clean. Containerfile-equivalent esbuild 0.25.5 rebuild probed in Chromium: exactly ONE #nav-toggle click listener on chat cold boot, /sources.html and login.html, and a touch tap opens the menu in all three states (pre-fix production: two listeners on cold boot = dead, three on sources = alive).
426 lines
19 KiB
JavaScript
426 lines
19 KiB
JavaScript
/* 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 a ≤46rem centered column;
|
|
* • any other → the raw content as a text node inside
|
|
* <pre class="doc-raw"> (mono, horizontal
|
|
* scroll);
|
|
* • 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) is written with textContent — 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.
|
|
*/
|
|
|
|
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) {
|
|
const el = document.createElement("span");
|
|
el.className = cls;
|
|
el.textContent = text;
|
|
return el;
|
|
}
|
|
|
|
/* ---------- 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 · indexed ·
|
|
* chunks), 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),
|
|
* <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;
|
|
metaEl.replaceChildren(
|
|
metaBadge("doc-source-badge", doc.source),
|
|
metaBadge("format-badge", doc.format),
|
|
pathCode,
|
|
metaBadge("doc-indexed", `Indexed ${fmtDate(doc.indexed_at)}`),
|
|
metaBadge("doc-chunks", `${doc.chunks} chunk${doc.chunks === 1 ? "" : "s"}`),
|
|
);
|
|
|
|
contentEl.replaceChildren();
|
|
// 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.
|
|
if (doc.summary && doc.summary.trim() !== "") {
|
|
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);
|
|
contentEl.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
|
|
contentEl.appendChild(wrap);
|
|
} else {
|
|
const pre = document.createElement("pre");
|
|
pre.className = "doc-raw";
|
|
pre.textContent = doc.content; // text node: never parsed as HTML
|
|
contentEl.appendChild(pre);
|
|
}
|
|
}
|
|
|
|
/* ---------- 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(""));
|
|
}
|
|
|
|
/* ---------- /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";
|
|
}
|
|
|
|
/* 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 });
|
|
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)
|
|
}
|
|
}
|
|
|
|
}
|