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:
2026-08-25 13:45:57 -04:00
parent 476aa0e066
commit fcde1fd37b
18 changed files with 1307 additions and 258 deletions
+68 -26
View File
@@ -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.
+200
View File
@@ -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
View File
@@ -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();
}
+24 -6
View File
@@ -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);
+167
View File
@@ -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); }
+32
View File
@@ -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
View File
@@ -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>