feat(ui): documents open in an almost-fullscreen modal instead of a new page — same-page overlay on chat + Sources, /document.html kept as the no-JS/direct-link fallback
This commit is contained in:
+68
-26
@@ -60,10 +60,20 @@
|
||||
* (thinking or answer). scrollReveal(wrap) is the single scroll gate;
|
||||
* `force` is reserved for the one-shot phase-14 restore landing.
|
||||
*
|
||||
* Document modal (phase 26): a source chip opens the cited document in
|
||||
* the almost-fullscreen modal overlay (assets/document-modal.js) on the
|
||||
* SAME page — no new tab, no navigation. The chip keeps its
|
||||
* /document.html href as the no-JS / context-menu escape hatch;
|
||||
* left-clicks are intercepted (preventDefault) and routed to
|
||||
* openDocumentModal. The module is loaded through the relative import
|
||||
* below — the header.js single-evaluation design (no direct <script>
|
||||
* tag; esbuild inlines it into the page bundle).
|
||||
*
|
||||
* All DOM ids match frontend/index.html.
|
||||
*/
|
||||
|
||||
import { fetchIsAdmin, initSharedHeader } from "./header.js";
|
||||
import { openDocumentModal } from "./document-modal.js"; // phase 26: chips open the same-page modal
|
||||
|
||||
const messagesEl = document.querySelector("#messages");
|
||||
const emptyState = document.querySelector("#empty-state");
|
||||
@@ -134,15 +144,19 @@ function scrollReveal(wrap, behavior = SCROLL, force = false) {
|
||||
}
|
||||
|
||||
/* ---------- document viewer link (phase 10; phase 13 adds `back`) ----------
|
||||
* Every cited document opens in the viewer, in a NEW tab. All query
|
||||
* values are percent-encoded: real paths contain slashes and sometimes
|
||||
* spaces, which would otherwise corrupt the query string. `back` tells the
|
||||
* viewer which page to return to when its back button is clicked — the
|
||||
* chips live in the chat, so chat passes "/" (the viewer validates it:
|
||||
* only same-origin relative URLs are honored; Sources links omit it and
|
||||
* get the viewer's /sources.html default). (The renderer
|
||||
* renderMarkdown/escapeHtml now lives in assets/markdown.js — a classic
|
||||
* script loaded by index.html and document.html before these modules.) */
|
||||
* The href a source chip carries: the dedicated viewer (no-JS /
|
||||
* context-menu escape hatch). Phase 26: the chip's left-click is
|
||||
* intercepted and the document opens in the same-page modal instead
|
||||
* (document-modal.js) — this URL is also what the modal's "Full page"
|
||||
* link points at. All query values are percent-encoded: real paths
|
||||
* contain slashes and sometimes spaces, which would otherwise corrupt
|
||||
* the query string. `back` tells the viewer which page to return to
|
||||
* when its back button is clicked — the chips live in the chat, so chat
|
||||
* passes "/" (the viewer validates it: only same-origin relative URLs
|
||||
* are honored; Sources links omit it and get the viewer's /sources.html
|
||||
* default). (The renderer renderMarkdown/escapeHtml now lives in
|
||||
* assets/markdown.js — a classic script loaded by index.html and
|
||||
* document.html before these modules.) */
|
||||
export function documentUrl(source, path, back = "/") {
|
||||
let url =
|
||||
"/document.html?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
|
||||
@@ -569,28 +583,53 @@ function autoGrow() {
|
||||
|
||||
/* ---------- chat turn (SSE streaming, PLAN §4) ---------- */
|
||||
|
||||
/* Cancel a response body without leaking an unhandled rejection:
|
||||
* while readSSE's reader is still attached, cancel() on a LOCKED stream
|
||||
* REJECTS (a rejected promise — a try/catch around the call cannot see
|
||||
* it), which surfaced as a "Cannot cancel a locked stream" page error on
|
||||
* every completed turn. Both outcomes are fine here: the stream is dead
|
||||
* or dying. */
|
||||
function cancelStream(res) {
|
||||
try {
|
||||
res?.body?.cancel().catch(() => {});
|
||||
} catch {
|
||||
/* body already consumed/closed */
|
||||
}
|
||||
}
|
||||
|
||||
/* Parse an SSE response body into JSON events. */
|
||||
async function readSSE(response, onEvent) {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
let sep;
|
||||
while ((sep = buf.indexOf("\n\n")) !== -1) {
|
||||
const frame = buf.slice(0, sep).trim();
|
||||
buf = buf.slice(sep + 2);
|
||||
if (!frame.startsWith("data:")) continue;
|
||||
const payload = frame.slice(5).trim();
|
||||
if (!payload || payload === "[DONE]") continue;
|
||||
onEvent(JSON.parse(payload));
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
let sep;
|
||||
while ((sep = buf.indexOf("\n\n")) !== -1) {
|
||||
const frame = buf.slice(0, sep).trim();
|
||||
buf = buf.slice(sep + 2);
|
||||
if (!frame.startsWith("data:")) continue;
|
||||
const payload = frame.slice(5).trim();
|
||||
if (!payload || payload === "[DONE]") continue;
|
||||
onEvent(JSON.parse(payload));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Release the reader's lock: with it held, the turn-end
|
||||
// cancelStream(res) below rejects (locked stream — unhandled
|
||||
// promise rejection). Released, the finished stream is closed and
|
||||
// cancel() settles quietly.
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
/* Source chips (mono, source/path) under a Brain bubble. */
|
||||
/* Source chips (mono, source/path) under a Brain bubble. Phase 26:
|
||||
* clicking a chip opens the document in the same-page modal (no new
|
||||
* tab) — the /document.html href stays as the no-JS / context-menu
|
||||
* escape hatch. */
|
||||
function appendSources(wrap, sources) {
|
||||
if (!sources || !sources.length) return;
|
||||
const body = wrap.querySelector(".msg-body");
|
||||
@@ -604,8 +643,11 @@ function appendSources(wrap, sources) {
|
||||
chip.className = "source-chip";
|
||||
chip.setAttribute("role", "listitem");
|
||||
chip.href = documentUrl(s.source, s.path, "/"); // back → the chat page
|
||||
chip.target = "_blank"; // open the full document in a new tab
|
||||
chip.rel = "noopener";
|
||||
chip.addEventListener("click", (e) => {
|
||||
e.preventDefault(); // no new tab (phase 26) — the modal takes over
|
||||
e.stopPropagation();
|
||||
openDocumentModal(s.source, s.path, chip);
|
||||
});
|
||||
chip.textContent = label;
|
||||
chip.title = label;
|
||||
meta.appendChild(chip);
|
||||
@@ -862,7 +904,7 @@ async function handleSend(e) {
|
||||
setUiState(UI_STATE.thinking);
|
||||
armTurnTimeout(() => {
|
||||
aborted = true;
|
||||
try { res?.body?.cancel(); } catch { /* already closed */ }
|
||||
cancelStream(res); // best-effort: the reader may still hold the lock
|
||||
setUiState(UI_STATE.error, "That's taking a long time — the answer may be stuck.");
|
||||
});
|
||||
|
||||
@@ -968,7 +1010,7 @@ async function handleSend(e) {
|
||||
// turn-local, so a page reload mid-stream leaves a usable composer.
|
||||
clearTurnTimeout();
|
||||
stopThinkingClock();
|
||||
try { res?.body?.cancel(); } catch { /* stream already closed */ }
|
||||
cancelStream(res); // the reader lock is released — no unhandled rejection
|
||||
if (uiState !== UI_STATE.idle) setUiState(UI_STATE.idle);
|
||||
// Phase 18: focus back for the next question, but never move the
|
||||
// viewport — a user reading earlier content stays where they are.
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/* Brain of Reese — document modal (phase 26, task 02).
|
||||
*
|
||||
* "New documents should open in an almost-fullscreen modal, not in a new
|
||||
* page" (TODO.md L4). This module is the SINGLE owner of the modal (the
|
||||
* way header.js owns the shared header): the chat page (app.js source
|
||||
* chips) and the Sources page (sources.js table links) each import
|
||||
* openDocumentModal(...) from here — one implementation, no duplicate
|
||||
* module instance, no direct <script> tag (esbuild inlines it into the
|
||||
* page bundle, same single-evaluation design as header.js).
|
||||
*
|
||||
* Contract:
|
||||
* • openDocumentModal(source, path, triggerEl) — shows the #doc-modal
|
||||
* overlay (the phase-26 task-01 skeleton) in its loading state,
|
||||
* moves focus into #doc-modal-content (tabindex="-1" — programmatic
|
||||
* focus target), fetches GET /api/documents/content (the SAME
|
||||
* stateless endpoint the /document.html page uses — identical
|
||||
* percent-encoding), and renders through document.js's shared
|
||||
* renderDocument: md/markdown → .doc-md via the escape-first
|
||||
* renderer, other formats → <pre class="doc-raw"> via textContent.
|
||||
* A !ok / network failure renders a short "document not found" line
|
||||
* in the content area.
|
||||
* • closeDocumentModal() — hides the overlay, removes the Escape/Tab
|
||||
* capture, and restores focus to the element that opened the modal
|
||||
* (best-effort: the trigger may have been removed from the DOM, e.g.
|
||||
* "New chat" clearing the list while the modal is open).
|
||||
* • Close affordances (modal UX standard): #doc-modal-close button,
|
||||
* backdrop click, and the Escape key (captured on document, so it
|
||||
* works no matter where focus is). While open, Tab / Shift+Tab are
|
||||
* trapped inside the panel.
|
||||
* • #doc-modal-open ("Full page") is the escape hatch to the
|
||||
* dedicated viewer: its href is set on open to the same
|
||||
* /document.html?source=…&path=… URL the chip/table link carries,
|
||||
* so the no-JS / direct-link page is always one click away.
|
||||
*
|
||||
* Import safety: the top level only binds controls that exist on the
|
||||
* page (a missing element is a no-op — the header.js pattern). document
|
||||
* .js is imported for renderDocument alone; its /document.html-specific
|
||||
* init is guarded there, so importing this module on the chat/sources
|
||||
* pages has no side effects beyond binding the modal itself.
|
||||
*/
|
||||
|
||||
import { renderDocument } from "./document.js";
|
||||
|
||||
const modalEl = document.querySelector("#doc-modal");
|
||||
const backdropEl = document.querySelector("#doc-modal-backdrop");
|
||||
const panelEl = document.querySelector("#doc-modal-panel");
|
||||
const titleEl = document.querySelector("#doc-modal-title");
|
||||
const metaEl = document.querySelector("#doc-modal-meta");
|
||||
const contentEl = document.querySelector("#doc-modal-content");
|
||||
const openEl = document.querySelector("#doc-modal-open");
|
||||
const closeEl = document.querySelector("#doc-modal-close");
|
||||
const descEl = document.querySelector("#doc-modal-desc");
|
||||
|
||||
/* The element that opened the modal — closeDocumentModal restores focus
|
||||
* to it (best-effort, see the header note). */
|
||||
let triggerEl = null;
|
||||
/* Monotonic fetch sequence: a close or a re-open for another document
|
||||
* must not render a stale response into the modal (rapid
|
||||
* open → close → open). */
|
||||
let fetchSeq = 0;
|
||||
|
||||
/* Same encoding the chips and the Sources table links use — both query
|
||||
* values percent-encoded (real paths contain slashes, sometimes spaces). */
|
||||
function contentUrl(source, path) {
|
||||
return "/api/documents/content?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
|
||||
}
|
||||
|
||||
function fullPageUrl(source, path) {
|
||||
return "/document.html?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
|
||||
}
|
||||
|
||||
function statusLine(text) {
|
||||
const p = document.createElement("p");
|
||||
p.className = "doc-modal-loading";
|
||||
p.setAttribute("role", "status");
|
||||
p.textContent = text;
|
||||
return p;
|
||||
}
|
||||
|
||||
function setDesc(text) {
|
||||
if (descEl) descEl.textContent = text; // the #doc-modal-desc role=status announcer
|
||||
}
|
||||
|
||||
function showLoading() {
|
||||
if (titleEl) titleEl.textContent = "Loading…";
|
||||
if (metaEl) metaEl.replaceChildren();
|
||||
if (contentEl) contentEl.replaceChildren(statusLine("Loading document…"));
|
||||
setDesc("Document content is loading.");
|
||||
}
|
||||
|
||||
function renderNotFound(path) {
|
||||
if (titleEl) titleEl.textContent = "Document not found";
|
||||
if (metaEl) metaEl.replaceChildren();
|
||||
if (contentEl) {
|
||||
contentEl.replaceChildren(
|
||||
statusLine(`“${path}” was not found — it may have been removed in a re-import.`),
|
||||
);
|
||||
}
|
||||
setDesc("Document not found.");
|
||||
}
|
||||
|
||||
/* Focus trap while the modal is open: Tab / Shift+Tab cycle within the
|
||||
* panel (the visible controls are "Full page" + Close; the content
|
||||
* target is programmatic-focus only, tabindex="-1"). */
|
||||
const FOCUSABLE =
|
||||
'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
function onModalKeydown(e) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
closeDocumentModal();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab" || !panelEl) return;
|
||||
const focusable = Array.from(panelEl.querySelectorAll(FOCUSABLE)).filter(
|
||||
(el) => el.getClientRects().length > 0,
|
||||
);
|
||||
if (!focusable.length) {
|
||||
e.preventDefault();
|
||||
if (contentEl) contentEl.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = document.activeElement;
|
||||
const inside = active !== null && panelEl.contains(active);
|
||||
if (e.shiftKey) {
|
||||
if (!inside || active === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else if (!inside || active === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/* Show the modal for (source, path) and fetch + render the document.
|
||||
* `trigger` (the chip / table link that was clicked) is remembered so
|
||||
* closeDocumentModal can return focus to it. */
|
||||
export function openDocumentModal(source, path, trigger = null) {
|
||||
if (!modalEl || !titleEl || !metaEl || !contentEl) {
|
||||
// No modal skeleton on this page (should not happen for the pages
|
||||
// that import this module) — fall back to the dedicated viewer
|
||||
// instead of swallowing the click.
|
||||
window.open(fullPageUrl(source, path), "_blank", "noopener");
|
||||
return;
|
||||
}
|
||||
triggerEl = trigger && typeof trigger.focus === "function" ? trigger : null;
|
||||
modalEl.hidden = false; // .doc-modal[hidden] { display:none } — visible now
|
||||
showLoading();
|
||||
if (openEl) {
|
||||
openEl.href = fullPageUrl(source, path); // the "Full page" escape hatch
|
||||
openEl.hidden = false;
|
||||
}
|
||||
document.addEventListener("keydown", onModalKeydown, true); // Escape + Tab trap
|
||||
contentEl.focus({ preventScroll: true }); // a11y: focus moves INTO the dialog
|
||||
|
||||
const seq = ++fetchSeq;
|
||||
fetch(contentUrl(source, path))
|
||||
.then(async (r) => {
|
||||
if (seq !== fetchSeq) return; // stale — closed or re-opened meanwhile
|
||||
if (!r.ok) {
|
||||
renderNotFound(path);
|
||||
return;
|
||||
}
|
||||
const doc = await r.json();
|
||||
if (seq !== fetchSeq) return;
|
||||
renderDocument(doc, { titleEl, metaEl, contentEl });
|
||||
setDesc("Document loaded.");
|
||||
})
|
||||
.catch(() => {
|
||||
if (seq === fetchSeq) renderNotFound(path);
|
||||
});
|
||||
}
|
||||
|
||||
/* Hide the modal, drop the Escape/Tab capture, and restore focus to the
|
||||
* triggering control (best-effort — it may have been removed from the
|
||||
* DOM since the modal opened). */
|
||||
export function closeDocumentModal() {
|
||||
if (!modalEl || modalEl.hidden) return;
|
||||
fetchSeq += 1; // any in-flight fetch is stale now
|
||||
modalEl.hidden = true;
|
||||
document.removeEventListener("keydown", onModalKeydown, true);
|
||||
if (openEl) {
|
||||
openEl.hidden = true;
|
||||
openEl.removeAttribute("href");
|
||||
}
|
||||
if (triggerEl && typeof triggerEl.focus === "function" && document.contains(triggerEl)) {
|
||||
triggerEl.focus({ preventScroll: true });
|
||||
}
|
||||
triggerEl = null;
|
||||
}
|
||||
|
||||
/* Top-level bindings — only on pages that carry the modal skeleton
|
||||
* (a missing element is a no-op, the header.js pattern). The Escape /
|
||||
* Tab capture is per-open (added/removed in open/close), so it never
|
||||
* leaks into the page behind a closed modal. */
|
||||
if (closeEl) closeEl.addEventListener("click", closeDocumentModal);
|
||||
if (backdropEl) backdropEl.addEventListener("click", closeDocumentModal);
|
||||
+135
-97
@@ -1,67 +1,48 @@
|
||||
/* Brain of Reese — document viewer (phase 10).
|
||||
/* Brain of Reese — document viewer (phase 10) + the shared document
|
||||
* renderer (phase 26).
|
||||
*
|
||||
* Reads `source`/`path` query params, fetches the stateless content
|
||||
* endpoint (GET /api/documents/content — database only, no filesystem),
|
||||
* and renders:
|
||||
* Two jobs:
|
||||
*
|
||||
* • 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).
|
||||
* 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).
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* A missing document (unknown pair, missing params, network error) shows
|
||||
* the designed not-found card with a link back to the Sources page.
|
||||
* (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), and the bar gains the New
|
||||
* chat button: on a non-chat page "new chat" means going to the chat,
|
||||
* fresh (clear the phase-14 conversation key, then navigate to "/").
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { clearChatStorage, fetchIsAdmin, initSharedHeader } from "./header.js";
|
||||
|
||||
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 — both entry points (chat source chips
|
||||
* and the Sources table) open the viewer in a NEW tab, where there is 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";
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
try {
|
||||
return new Date(iso).toLocaleString();
|
||||
@@ -81,9 +62,15 @@ function metaBadge(cls, text) {
|
||||
return el;
|
||||
}
|
||||
|
||||
function render(doc) {
|
||||
/* ---------- 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 — .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 is a text node. */
|
||||
export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
|
||||
titleEl.textContent = doc.title;
|
||||
document.title = `${doc.title} · Brain of Reese`;
|
||||
|
||||
const pathCode = document.createElement("code");
|
||||
pathCode.className = "doc-path";
|
||||
@@ -96,7 +83,6 @@ function render(doc) {
|
||||
metaBadge("doc-chunks", `${doc.chunks} chunk${doc.chunks === 1 ? "" : "s"}`),
|
||||
);
|
||||
|
||||
notFoundEl.hidden = true;
|
||||
contentEl.replaceChildren();
|
||||
if (doc.format === "md" || doc.format === "markdown") {
|
||||
const wrap = document.createElement("div");
|
||||
@@ -111,52 +97,104 @@ function render(doc) {
|
||||
}
|
||||
}
|
||||
|
||||
function showNotFound() {
|
||||
titleEl.textContent = "Document not found";
|
||||
document.title = "Document not found · Brain of Reese";
|
||||
metaEl.replaceChildren();
|
||||
contentEl.replaceChildren();
|
||||
notFoundEl.hidden = false;
|
||||
}
|
||||
/* ---------- /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") || "";
|
||||
|
||||
/* Phase 19: the shared header controls (Sign in / Sign out — exactly one
|
||||
* visible) are toggled here; the viewer has no nav, so there is no
|
||||
* #nav-sources for the module to touch. Independent of the doc fetch
|
||||
* (its own IIFE — load() below never waits on it).
|
||||
* (fetchIsAdmin is imported for parity with the other header consumers —
|
||||
* the module's cached promise is the single whoami per page either way.) */
|
||||
(async () => {
|
||||
await initSharedHeader();
|
||||
})();
|
||||
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");
|
||||
|
||||
/* Phase 19: New chat on a non-chat page means "go to the chat, fresh":
|
||||
* clear the phase-14 conversation key, then land on the chat page — its
|
||||
* empty state, since the conversation is gone from storage. */
|
||||
const newChatBtn = document.querySelector("#new-chat-btn");
|
||||
if (newChatBtn) {
|
||||
newChatBtn.addEventListener("click", () => {
|
||||
clearChatStorage();
|
||||
window.location.href = "/";
|
||||
});
|
||||
}
|
||||
|
||||
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)
|
||||
/* 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";
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
/* 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} · Brain of Reese`;
|
||||
}
|
||||
|
||||
function showNotFound() {
|
||||
titleEl.textContent = "Document not found";
|
||||
document.title = "Document not found · Brain of Reese";
|
||||
metaEl.replaceChildren();
|
||||
contentEl.replaceChildren();
|
||||
notFoundEl.hidden = false;
|
||||
}
|
||||
|
||||
/* Phase 19: the shared header controls (Sign in / Sign out — exactly
|
||||
* one visible) are toggled here; the viewer has no nav, so there is
|
||||
* no #nav-sources for the module to touch. Independent of the doc
|
||||
* fetch (its own IIFE — load() below never waits on it).
|
||||
* (fetchIsAdmin is imported for parity with the other header
|
||||
* consumers — the module's cached promise is the single whoami per
|
||||
* page either way.) */
|
||||
(async () => {
|
||||
await initSharedHeader();
|
||||
})();
|
||||
|
||||
/* Phase 19: New chat on a non-chat page means "go to the chat,
|
||||
* fresh": clear the phase-14 conversation key, then land on the chat
|
||||
* page — its empty state, since the conversation is gone from storage. */
|
||||
const newChatBtn = document.querySelector("#new-chat-btn");
|
||||
if (newChatBtn) {
|
||||
newChatBtn.addEventListener("click", () => {
|
||||
clearChatStorage();
|
||||
window.location.href = "/";
|
||||
});
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
}
|
||||
|
||||
@@ -10,9 +10,19 @@
|
||||
* page, shared with the header toggling), and the header gains the New
|
||||
* chat button: on a non-chat page "new chat" means going to the chat,
|
||||
* fresh (clear the phase-14 conversation key, then navigate to "/").
|
||||
*
|
||||
* Phase 26: the table's path links open the document in the
|
||||
* almost-fullscreen modal overlay (assets/document-modal.js) on the
|
||||
* SAME page — no new tab, no navigation. The link keeps its
|
||||
* /document.html href as the no-JS / context-menu escape hatch;
|
||||
* left-clicks are intercepted (preventDefault) and routed to
|
||||
* openDocumentModal. The module is loaded through the relative import
|
||||
* below — the header.js single-evaluation design (no direct <script>
|
||||
* tag; esbuild inlines it into the page bundle).
|
||||
*/
|
||||
|
||||
import { clearChatStorage, fetchIsAdmin, initSharedHeader } from "./header.js";
|
||||
import { openDocumentModal } from "./document-modal.js"; // phase 26: row links open the same-page modal
|
||||
|
||||
const tbody = document.querySelector("#docs-tbody");
|
||||
const emptyEl = document.querySelector("#sources-empty");
|
||||
@@ -52,8 +62,11 @@ function fmtDate(iso) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Viewer link (phase 10) — same encoded URL the chat chips use; both query
|
||||
* values are percent-encoded (paths contain slashes, sometimes spaces). */
|
||||
/* Viewer link (phase 10) — same encoded URL the chat chips use; both
|
||||
* query values are percent-encoded (paths contain slashes, sometimes
|
||||
* spaces). Phase 26: this is the href the .doc-link CARRIES (no-JS /
|
||||
* context-menu escape hatch) — the left-click opens the same-page modal
|
||||
* instead. */
|
||||
export function documentUrl(source, path) {
|
||||
return "/document.html?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
|
||||
}
|
||||
@@ -98,15 +111,20 @@ function makeRow(d) {
|
||||
sourceTd.textContent = d.source; // document-derived text — never innerHTML
|
||||
tr.appendChild(sourceTd);
|
||||
|
||||
// Path cell: a link to the document viewer (phase 10), full path as the
|
||||
// accessible/hover name (the column is ellipsized).
|
||||
// Path cell: a link to the document (phase 10), full path as the
|
||||
// accessible/hover name (the column is ellipsized). Phase 26: the
|
||||
// left-click opens the same-page modal — no new tab (document-modal.js);
|
||||
// the href stays as the no-JS / context-menu escape hatch.
|
||||
const pathTd = document.createElement("td");
|
||||
pathTd.title = d.path; // full path on hover (column is ellipsized)
|
||||
const link = document.createElement("a");
|
||||
link.className = "doc-link";
|
||||
link.href = documentUrl(d.source, d.path);
|
||||
link.target = "_blank"; // open the full document in a new tab
|
||||
link.rel = "noopener";
|
||||
link.addEventListener("click", (e) => {
|
||||
e.preventDefault(); // no new tab (phase 26) — the modal takes over
|
||||
e.stopPropagation();
|
||||
openDocumentModal(d.source, d.path, link);
|
||||
});
|
||||
link.title = d.path; // full path as the link's hover/accessible name
|
||||
link.textContent = d.path;
|
||||
pathTd.appendChild(link);
|
||||
|
||||
@@ -1204,6 +1204,163 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
}
|
||||
.doc-link:hover, .doc-link:focus-visible { text-decoration: underline; }
|
||||
|
||||
/* ---------- Document modal (phase 26) ----------
|
||||
"New documents should open in an almost-fullscreen modal, not in a new
|
||||
page" (TODO.md L4). The overlay reuses the viewer page's .doc-meta
|
||||
badge classes, the .doc-md ≤46rem reading column, and the .doc-raw
|
||||
pre — this block only adds the chrome (backdrop, panel, header,
|
||||
actions, scroll container). Phase-08 tokens only; NO blur (the
|
||||
phase-08 no-blur perf anchor); no new assets; system fonts.
|
||||
|
||||
Stacking: z-index 1000 puts the overlay above the sticky header (20)
|
||||
and the skip-link (100); the z-index:-1 background layers stay below
|
||||
everything. The panel is 96vw × 92vh, centered ("almost-fullscreen"). */
|
||||
.doc-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex; /* the panel is the only in-flow child — margin: auto centers it */
|
||||
}
|
||||
/* Explicit (the global [hidden] rule already wins — this one is the
|
||||
documented, testable contract for the skeleton). */
|
||||
.doc-modal[hidden] { display: none; }
|
||||
|
||||
.doc-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
/* --bg at 82% — no backdrop-filter (phase-08 no-blur perf anchor). */
|
||||
background: rgba(10, 14, 23, 0.82);
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.doc-modal-panel {
|
||||
/* position:relative lifts the panel above the fixed backdrop (positioned
|
||||
elements paint over in-flow siblings otherwise). */
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(1100px, 96vw);
|
||||
height: 92vh;
|
||||
margin: auto;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 24px 80px rgb(0 0 0 / 0.55);
|
||||
}
|
||||
|
||||
/* Sticky top with the SAME height as the page bars — the phase-12 pins
|
||||
(--header-h: 64px desktop / 58px ≤640px) so the bar never reads
|
||||
differently here. The title is the designated squeeze target (ellipsis),
|
||||
so the bar can never grow its height. */
|
||||
.doc-modal-header {
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.9rem;
|
||||
height: var(--header-h);
|
||||
padding-inline: 1.25rem;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.doc-modal-title {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
line-height: 1.3;
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.doc-modal-actions {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* "Full page" escape hatch: the same ghost pill as New chat / Sign in
|
||||
(ink-soft on surface ≈6.9:1; hover pair brand-ink/brand-soft ≈6.9:1).
|
||||
Icon-only below 640px — the aria-label keeps the accessible name. */
|
||||
.doc-modal-open {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 44px;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
}
|
||||
.doc-modal-open:hover { background: var(--brand-soft); color: var(--brand-ink); }
|
||||
.doc-modal-open svg { width: 15px; height: 15px; display: none; }
|
||||
|
||||
/* Icon-only close (aria-label in the markup). ink-soft on surface ≈6.9:1;
|
||||
hover = the err pair ≈9.1:1, like the steering-note delete. */
|
||||
.doc-modal-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
cursor: pointer;
|
||||
}
|
||||
.doc-modal-close:hover { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
|
||||
.doc-modal-close:focus-visible {
|
||||
outline: 3px solid var(--brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.doc-modal-close svg { width: 18px; height: 18px; display: block; }
|
||||
|
||||
/* Meta row: the SAME badge classes as the viewer's .doc-meta (source
|
||||
badge · format badge · mono path · indexed · chunks — no duplicate
|
||||
badge styling here); unlike the fixed-height header bar it may WRAP,
|
||||
so nothing clips. aria-live="polite" on the element announces the
|
||||
load → meta swap (phase-10 a11y contract, modal variant). */
|
||||
.doc-modal-meta {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.6rem 1.25rem 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
/* The scroll container: vertical scroll lives HERE, never the viewport.
|
||||
.doc-md keeps its ≤46rem centered reading column inside; .doc-raw keeps
|
||||
its own overflow-x. tabindex="-1" in the markup is the JS focus target. */
|
||||
.doc-modal-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 1rem 1.25rem 1.5rem;
|
||||
}
|
||||
.doc-modal-loading { margin: 1.5rem auto; text-align: center; color: var(--ink-soft); }
|
||||
|
||||
/* No motion under reduced motion (same pattern as the phase-25 layers). */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.doc-modal-backdrop { transition: none; }
|
||||
}
|
||||
|
||||
/* ---------- Footer ---------- */
|
||||
.app-footer {
|
||||
border-top: 1px solid var(--line);
|
||||
@@ -1270,6 +1427,16 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
.doc-path { max-width: 16rem; }
|
||||
.doc-md { padding: 1.1rem 1rem; }
|
||||
.doc-raw { padding: 1rem; font-size: 0.8rem; }
|
||||
/* Phase 26: the modal bar squeezes like the other bars — the Full page
|
||||
pill goes icon-only (aria-label keeps the name), the title clips;
|
||||
the panel stays 96vw × 92vh, so no horizontal overflow at 360px. */
|
||||
.doc-modal-header { gap: 0.5rem; padding-inline: 0.9rem; }
|
||||
.doc-modal-title { font-size: 1.1rem; }
|
||||
.doc-modal-open { padding: 0.4rem 0.55rem; }
|
||||
.doc-modal-open svg { display: block; }
|
||||
.doc-modal-open span { display: none; }
|
||||
.doc-modal-meta { padding-inline: 0.9rem; }
|
||||
.doc-modal-content { padding: 0.75rem 0.9rem 1.25rem; }
|
||||
.composer { padding: 0.5rem; }
|
||||
.footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; }
|
||||
main { padding-bottom: env(safe-area-inset-bottom, 0); }
|
||||
|
||||
@@ -117,5 +117,37 @@
|
||||
own `import "./header.js"` — a hoisted import that is evaluated
|
||||
before the page script body calls initSharedHeader() at boot. -->
|
||||
<script type="module" src="/assets/app.js"></script>
|
||||
|
||||
<!-- Phase 26: the almost-fullscreen document modal. Source chips and
|
||||
Sources-table path links open documents here (same-page overlay,
|
||||
no new tab) instead of navigating to /document.html — that page
|
||||
stays as the no-JS / direct-link fallback, unchanged. The page
|
||||
scripts fetch /api/documents/content and render into
|
||||
#doc-modal-content; the hidden attribute keeps the skeleton inert
|
||||
until JS opens it. #doc-modal-open points at the same
|
||||
/document.html?source=…&path=… URL the modal builds, so the
|
||||
dedicated page is always one click away. -->
|
||||
<div class="doc-modal" id="doc-modal" hidden>
|
||||
<div class="doc-modal-backdrop" id="doc-modal-backdrop" aria-hidden="true"></div>
|
||||
<div class="doc-modal-panel" id="doc-modal-panel" role="dialog" aria-modal="true" aria-labelledby="doc-modal-title" aria-describedby="doc-modal-desc">
|
||||
<header class="doc-modal-header">
|
||||
<h2 class="doc-modal-title" id="doc-modal-title">Loading…</h2>
|
||||
<div class="doc-modal-actions">
|
||||
<a class="doc-modal-open" id="doc-modal-open" target="_blank" rel="noopener" hidden aria-label="Open in full page">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14 21 3"/></svg>
|
||||
<span>Full page</span>
|
||||
</a>
|
||||
<button type="button" class="doc-modal-close" id="doc-modal-close" aria-label="Close document">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="doc-modal-meta" id="doc-modal-meta" aria-live="polite"></div>
|
||||
<p class="visually-hidden" id="doc-modal-desc" role="status">Document content is loading.</p>
|
||||
<main class="doc-modal-content" id="doc-modal-content" tabindex="-1">
|
||||
<p class="doc-modal-loading" role="status">Loading document…</p>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+38
-1
@@ -121,7 +121,44 @@
|
||||
|
||||
<!-- Phase 19: the shared header module loads through the page script's
|
||||
own `import "./header.js"` — a hoisted import that is evaluated
|
||||
before the page script body calls initSharedHeader() at boot. -->
|
||||
before the page script body calls initSharedHeader() at boot.
|
||||
Phase 26: markdown.js (the classic global renderMarkdown) loads
|
||||
BEFORE the module script — the document modal renders md
|
||||
documents through it on this page too. -->
|
||||
<script src="assets/markdown.js"></script>
|
||||
<script type="module" src="/assets/sources.js"></script>
|
||||
|
||||
<!-- Phase 26: the almost-fullscreen document modal — SAME skeleton as
|
||||
the chat page (index.html): Sources-table path links open documents
|
||||
here (same-page overlay, no new tab) instead of navigating to
|
||||
/document.html, which stays the no-JS / direct-link fallback,
|
||||
unchanged. The page script fetches /api/documents/content and
|
||||
renders into #doc-modal-content through the shared renderDocument
|
||||
(document.js); the hidden attribute keeps the skeleton inert until
|
||||
JS opens it. #doc-modal-open points at the same
|
||||
/document.html?source=…&path=… URL the link carries, so the
|
||||
dedicated page is always one click away. -->
|
||||
<div class="doc-modal" id="doc-modal" hidden>
|
||||
<div class="doc-modal-backdrop" id="doc-modal-backdrop" aria-hidden="true"></div>
|
||||
<div class="doc-modal-panel" id="doc-modal-panel" role="dialog" aria-modal="true" aria-labelledby="doc-modal-title" aria-describedby="doc-modal-desc">
|
||||
<header class="doc-modal-header">
|
||||
<h2 class="doc-modal-title" id="doc-modal-title">Loading…</h2>
|
||||
<div class="doc-modal-actions">
|
||||
<a class="doc-modal-open" id="doc-modal-open" target="_blank" rel="noopener" hidden aria-label="Open in full page">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14 21 3"/></svg>
|
||||
<span>Full page</span>
|
||||
</a>
|
||||
<button type="button" class="doc-modal-close" id="doc-modal-close" aria-label="Close document">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="doc-modal-meta" id="doc-modal-meta" aria-live="polite"></div>
|
||||
<p class="visually-hidden" id="doc-modal-desc" role="status">Document content is loading.</p>
|
||||
<main class="doc-modal-content" id="doc-modal-content" tabindex="-1">
|
||||
<p class="doc-modal-loading" role="status">Loading document…</p>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user