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
@@ -0,0 +1,44 @@
# Phase 26 — Document Modal Viewer
**Source:** `TODO.md L4 — "New documents should open in an almost-fullscreen modal, not in a new page"`
**Story:** `.agent/user_stories/document-modal.md`
**Context:** Phase 10 added the separate `/document.html` viewer page; phase 19 added the shared header bar that now lives on every page. The document content is served by the stateless `GET /api/documents/content` endpoint (PLAN §4).
## Objective
Stop opening cited documents in a new page/tab. Clicking a source chip or a Sources-table path link now opens the document in an **almost-fullscreen modal overlay** on the current page, fed by the same `/api/documents/content` endpoint. The existing `/document.html` page stays as the no-JS / direct-link fallback and its behaviour is unchanged.
## Dependencies
- `10_story_document_viewer` (complete) — the `/document.html` page, the `document.js` renderer, the `renderMarkdown` escape-first renderer in `markdown.js`, and the `#doc-content` / `.doc-md` / `.doc-raw` markup this phase reuses inside the modal.
- `19_shared_header` (complete) — the shared header bar the modal sits under; the modal must not disturb the header.
- `08_story_dark_tech_theme` (complete) — the Phase-08 tokens and the ≥4.5:1 contrast / `prefers-reduced-motion` contract the modal must honour.
## Tasks
1. `01_modal_css_and_html.md` — the modal CSS (overlay, backdrop, close button, scrollable content area) + inject the modal skeleton into `index.html`
2. `02_app_js_modal_intercept.md` — intercept document links in `app.js` + `sources.js`, fetch content via `/api/documents/content`, render inside the modal
3. `03_document_js_modal_mode.md` — adapt `document.js` to optionally render in modal mode (reuse the same API call) for the direct-link fallback path
4. `04_e2e_regression_suite.md` — update `test_document_viewer.py` to verify modal behaviour; the story gate, run in isolation
## Testing & Quality
- Unit/integration: none required for the modal itself (frontend-only); the `/api/documents/content` endpoint is unchanged (no `app/` change → no coverage delta).
- Coverage: frontend-only; the >90% `app/` gate is unaffected.
- E2E: `tests/e2e/test_document_viewer.py` rewritten for the modal contract (task 4), green **in isolation** (prereq `podman compose up -d db`).
## Completion Criteria
- [ ] Clicking a source chip (chat) or a Sources-table path link opens the document in an almost-fullscreen modal on the **same page** (no new tab, no navigation).
- [ ] The modal renders the same content the `/document.html` page renders: md/markdown via the shared renderer (`.doc-md`), other formats in `<pre class="doc-raw">`, source/format/path/indexed/chunks meta.
- [ ] The modal has a visible close control, closes on Escape, closes on backdrop click, and keeps the dark theme + a11y frame (skip-link, focus trap, `:focus-visible`, aria-label).
- [ ] The existing `/document.html` page still works unchanged (direct link, back button, XSS-safe rendering, not-found state).
- [ ] No CDN tags on any touched page; every asset reference is same-origin or `data:`.
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL ≥ pre-change number (gate >90%).
- [ ] `uv run pytest tests/e2e/test_document_viewer.py -v --no-cov` green in isolation.
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] UI Structure Check (AGENTS.md rule 5): modal content uses the standard centered column width for md; backdrop behind content; no 360px overflow.
- [ ] `.agent/user_stories/document-modal.md` exists.
- [ ] One `--no-gpg-sign` commit staging only this phase's files; `.agent/phases/todo/26_document_modal_viewer/` moved to `.agent/phases/complete/`.
## Locked decisions
- **No backend change** — the modal reuses `GET /api/documents/content` unchanged (A10 untouched: the API stays stateless).
- **A11 untouched** — vanilla HTML/CSS/JS, no CDN, zero new packages, no new assets, system font stack; the modal is pure CSS + JS.
- **No anchor revised** — this is a UI-behaviour change (PLAN §7.5 gains `#doc-modal`, `#doc-modal-backdrop`, `#doc-modal-close`, `#doc-modal-content`); the `/document.html` page and its story are unchanged.
- **A16 honoured** — one story E2E suite (rewritten) + adapted regressions.
- **A17 honoured** — one atomic `--no-gpg-sign` commit.
@@ -0,0 +1,62 @@
# Task 01 — Modal CSS + HTML skeleton
**Phase:** `26_document_modal_viewer` · **Source:** `TODO.md:4 — "New documents should open in an almost-fullscreen modal, not in a new page"`
**Story:** `.agent/user_stories/document-modal.md`
## Objective
Add the modal markup to `index.html` and the CSS that styles an almost-fullscreen overlay (backdrop + panel + close button + scrollable content) using the Phase-08 tokens.
## Work
1. `frontend/index.html` — insert the modal skeleton just before the closing `</body>` (after the existing script tags, or before them — order doesn't matter for a static skeleton). The skeleton:
```html
<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" 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>
```
The `hidden` attribute keeps the modal off until JS opens it. The `#doc-modal-open` "Full page" link points at the same `/document.html?source=…&path=…&modal=…` URL the modal will build so a user can still open the dedicated page if JS is off.
2. `frontend/assets/styles.css` — add a `--doc-modal-*` block (Phase-08 tokens). Styling contract:
- `.doc-modal` — `position: fixed; inset: 0; z-index: 1000;` (above the shared header and every page layer, below the phase-25 background which is `z-index: -1`); the panel is flex, column; the backdrop + panel fill the viewport.
- `.doc-modal-backdrop` — `position: fixed; inset: 0; background: rgba(10,14,23,0.82);` backdrop blur is **not** used (phase-08 no-blur perf anchor); `opacity` transition 120ms.
- `.doc-modal-panel` — `display: flex; flex-direction: column; width: min(1100px, 96vw); height: 92vh; margin: auto; background: var(--surface, #121a2e); border: 1px solid var(--line, #232b52); border-radius: 12px; box-shadow: 0 24px 80px rgba(0,0,0,.55);` — "almost-fullscreen" = 96vw × 92vh, centered.
- `.doc-modal-header` — sticky top, same height/spacing as the doc header (64px / 58px pins from phase 12); title uses `--ink`; close button ≥44px target, focus-visible ring.
- `.doc-modal-content` — `flex: 1; overflow: auto;` (vertical scroll inside the panel, not the viewport); padding; the md content reuses `.doc-md` (≤46rem centered column) — the modal just provides the scroll container. For wide raw formats the `.doc-raw` pre already has `overflow-x: auto`.
- `.doc-modal-meta` — reuses the `.doc-meta` styling already defined for the viewer page (source/format/path/indexed/chunks badges); keep it compact (single row, wrap).
- `.doc-modal-close` — icon-only button, `aria-label` kept, `:focus-visible` 3px ring.
- Transitions respect `prefers-reduced-motion: reduce` (no opacity/transform animation, or `animation: none` under the reduced-motion media query — same pattern as the phase-25 background layers).
- `.doc-modal[hidden]` — `display: none` (the `hidden` IDL attribute default already hides it; add the rule to be explicit and testable).
- Ensure the modal panel does not add horizontal width at 360px (no `box-sizing` surprises; the panel is `96vw` ≤ viewport).
3. Verify the new CSS classes do not collide with any existing selector in `styles.css` (grep for `.doc-modal`, `.doc-modal-`).
## ASSUMPTIONS
- The modal panel is `96vw × 92vh` ("almost-fullscreen"). If the owner wants a different fraction, that's a follow-up.
- The "Full page" link is admin-agnostic (it just opens `/document.html`); it is shown for everyone since the viewer is public.
- The modal uses the existing `.doc-meta` badge classes already defined for the viewer page (no duplicate styling).
## Testing & Quality
- No unit/integration test for static CSS/HTML.
- Coverage: frontend-only; the >90% `app/` gate is unaffected.
## Completion Criteria
- [ ] `index.html` contains the `.doc-modal` skeleton with the documented ids (`#doc-modal`, `#doc-modal-backdrop`, `#doc-modal-panel`, `#doc-modal-close`, `#doc-modal-title`, `#doc-modal-meta`, `#doc-modal-content`, `#doc-modal-open`).
- [ ] The modal CSS block is present, uses Phase-08 tokens, has no `filter: blur`/`backdrop-filter`, and the panel is `96vw × 92vh` centered.
- [ ] No selector collision (grep clean).
- [ ] `prefers-reduced-motion` stills any modal transition.
@@ -0,0 +1,40 @@
# Task 02 — Intercept document links → modal
**Phase:** `26_document_modal_viewer` · **Source:** `TODO.md:4 — "New documents should open in an almost-fullscreen modal, not in a new page"`
**Story:** `.agent/user_stories/document-modal.md`
## Objective
Intercept document links on the chat page (`app.js` source chips) and the Sources page (`sources.js` table links): instead of navigating to `/document.html` in a new tab, fetch the document via `GET /api/documents/content` and render it inside the modal from task 01.
## Work
1. `frontend/assets/document.js` — extract the rendering logic into a reusable, importable function so both the standalone page (task 03) and the modal share the exact same renderer. Specifically:
- Export `renderDocument(doc, { containerEl, metaEl, titleEl })` that populates a title element, a meta element (the `.doc-meta` badges), and a content element (`.doc-md` for markdown via `renderMarkdown`, `<pre class="doc-raw">` otherwise). Keep the escape-first XSS-safety contract (`innerHTML` only through `renderMarkdown`; `textContent` for raw + badges).
- The standalone `document.html` page keeps its own `load()` that calls `renderDocument` with its page elements (task 03 wires this).
2. `frontend/assets/app.js` — add a `openDocumentModal(source, path)` helper:
- Build the modal URL: `/api/documents/content?source=…&path=…` (same encoding the chips already use).
- Show the modal: set `#doc-modal.hidden = false`, set the loading state, move focus into `#doc-modal-content` (a11y — the panel is `tabindex="-1"`).
- `fetch(contentUrl)` → on `!r.ok` render a short "document not found" line in the content area; on success call `renderDocument` with `#doc-modal-title`, `#doc-modal-meta`, `#doc-modal-content`.
- The "Full page" link (`#doc-modal-open`) is set to the `/document.html?source=…&path=…` URL on open.
- Keep the existing `documentUrl()` builder for the "Full page" link (unchanged output).
- Add modal close behaviour: `#doc-modal-close` click → `closeDocumentModal()`; backdrop click → close; `Escape` key → close; closing restores focus to the link that opened the modal (best-effort — store the triggering element).
3. `frontend/assets/app.js` — wire the source chips: replace `chip.target = "_blank"` navigation with `chip.addEventListener("click", e => { e.preventDefault(); e.stopPropagation(); openDocumentModal(s.source, s.path, chip); })`. Keep the `title`/aria-label truncation logic the chips already have. The chip keeps its `href` too (no-JS fallback would navigate to `/document.html`).
4. `frontend/assets/sources.js` — wire the table links the same way: the `.doc-link` click is intercepted, `preventDefault`, and `openDocumentModal(d.source, d.path, link)` is called. Since `openDocumentModal` lives in `app.js` (the chat page module) and `sources.js` is a separate module, **export** `openDocumentModal` from `app.js` and import it in `sources.js` — but `app.js` is loaded as a module on the chat page only. To avoid a second module instance, move the shared modal logic into a small new module `frontend/assets/document-modal.js` (task 02 step 1 refined below) and have both `app.js` and `sources.js` import it.
- **Refined split:** create `frontend/assets/document-modal.js` exporting `openDocumentModal(source, path, triggerEl)` and `closeDocumentModal()`. This module owns the modal DOM wiring (close on ESC / backdrop / button, focus management) and the `fetch` + `renderDocument` call. `app.js` and `sources.js` just call `openDocumentModal(...)` from their click handlers. This is the cleanest single-implementation approach (mirrors how `header.js` is the single owner of the shared header).
- `document.js` (standalone page) also imports `renderDocument` from itself (or a shared `document-render.js`) — keep the standalone page self-contained; it doesn't need the modal module.
## ASSUMPTIONS
- The modal module (`document-modal.js`) is a classic or module script loaded on both `index.html` and `sources.html`. It's a module (imports `renderDocument` from `document.js`), so both pages must load it via `<script type="module">`. `document.js` will export `renderDocument`.
- Close-on-`Escape` and close-on-backdrop are modal UX standards; the owner's item says "modal, not a new page", which implies standard modal affordances.
- The "Full page" link remains for users who want the dedicated viewer; it is optional and doesn't interfere with the modal.
## Testing & Quality
- No unit/integration test (frontend-only).
- Coverage: frontend-only; the >90% `app/` gate is unaffected.
## Completion Criteria
- [ ] Clicking a source chip or a Sources-table path link opens the modal and renders the document (md via `.doc-md`, other formats via `.doc-raw`).
- [ ] The modal closes on button click, on backdrop click, and on `Escape`; focus returns to the triggering control.
- [ ] No new tab opens from either link type.
- [ ] The "Full page" link still navigates to `/document.html` (unchanged).
- [ ] XSS-safe rendering preserved (markdown escaped, raw set via `textContent`).
- [ ] Both pages load the modal module without a duplicate-module error.
@@ -0,0 +1,29 @@
# Task 03 — Standalone viewer page reuses the shared renderer
**Phase:** `26_document_modal_viewer` · **Source:** `TODO.md:4 — "New documents should open in an almost-fullscreen modal, not in a new page"`
**Story:** `.agent/user_stories/document-modal.md`
## Objective
Keep `/document.html` working exactly as before (it is the no-JS / direct-link fallback) but refactor its `document.js` so the markdown/raw rendering lives in a shared function the modal module can reuse. No behavioural change to the standalone page.
## Work
1. `frontend/assets/document.js` — split the current inline renderer into an exported `renderDocument(doc, { titleEl, metaEl, contentEl })` function (the escape-first contract: markdown → `renderMarkdown` into a `.doc-md` div; other formats → `<pre class="doc-raw">` via `textContent`; badges via `textContent`). The page's existing `load()` IIFE now calls `renderDocument` with the page's `#doc-title`, `#doc-meta`, `#doc-content` elements. Everything else in `document.js` (query-param parsing, `back` target, not-found card, shared header wiring, New Chat button, `mainEl.focus()`) is **unchanged**.
2. `frontend/assets/document-modal.js` (new) — imports `renderDocument` from `./document.js`. Owns `openDocumentModal(source, path, triggerEl)` and `closeDocumentModal()` (see task 02). On open it fetches `/api/documents/content` and calls `renderDocument(doc, { titleEl: #doc-modal-title, metaEl: #doc-modal-meta, contentEl: #doc-modal-content })`. It also sets `#doc-modal-open.href` to the `/document.html?source=…&path=…` URL.
3. `frontend/index.html` — load `document-modal.js` as a module (add `<script type="module" src="/assets/document-modal.js"></script>` alongside the existing `app.js` module script). `index.html` already loads `markdown.js` as a classic script (needed by `renderDocument`).
4. `frontend/sources.html` — load `document-modal.js` as a module (it needs `document.js`'s `renderDocument`, so both `document.js` and `document-modal.js` must be module scripts; `markdown.js` classic script stays). The Sources page currently loads `sources.js` as a module; add the modal module script next to it.
5. Verify the no-CDN integration test (`tests/integration/test_api.py::test_index_html_served_locally`) still passes — the new module scripts are same-origin `<script src>`, so they satisfy the "local asset" rule. If the test counts script tags, update the expected count.
## ASSUMPTIONS
- `renderDocument` depends on `renderMarkdown` (from `markdown.js`), which is a classic script — so `document.js` (module) importing nothing but using the global `renderMarkdown` is fine, and `document-modal.js` (module) importing `renderDocument` from `document.js` also relies on the global `renderMarkdown` being present. Both pages load `markdown.js` before the module scripts (hoisting guarantees module scripts run after classic scripts already on the page).
- The standalone page's `document.js` no longer needs to be a module for its own rendering — but it stays a module because it imports `header.js` (shared header). Keep it a module.
## Testing & Quality
- No unit/integration test for the refactor itself.
- Coverage: frontend-only; the >90% `app/` gate is unaffected.
## Completion Criteria
- [ ] `/document.html?source=…&path=…` still renders title/meta/content exactly as before (md in `.doc-md`, raw in `<pre.doc-raw>`).
- [ ] The not-found state, `back` target, shared header, and New Chat button on `/document.html` are unchanged.
- [ ] `document-modal.js` is loaded on `index.html` and `sources.html`; `document.js` exports `renderDocument`.
- [ ] No-CDN test still passes (new scripts are same-origin).
- [ ] No console errors on any of the three pages.
@@ -0,0 +1,33 @@
# Task 04 — E2E story suite (modal) + regressions
**Phase:** `26_document_modal_viewer` · **Source:** `TODO.md:4 — "New documents should open in an almost-fullscreen modal, not in a new page"`
**Story:** `.agent/user_stories/document-modal.md`
## Objective
Rewrite the phase-10 E2E suite to assert the **modal** contract (open in a modal on the same page, no new tab; close on button/Escape/backdrop; dark theme; no CDN; a11y frame), and confirm the standalone `/document.html` page still works.
## Work
1. `tests/e2e/test_document_viewer.py` — rewrite for the modal contract (the seeding harness from the phase-10 file — `_import_fixtures` / `_reset_db` / `_run_in_thread` — stays identical; only the assertions change):
- `test_source_chip_opens_modal` — from the chat page, ask the QUESTION, wait for the `kubernetes.md` source chip, click it (no `target=_blank` click → `expect_popup`); assert the modal `.doc-modal` is visible, NOT hidden; `#doc-modal-title` = "Kubernetes Homelab Cluster"; `#doc-content`/`.doc-md` present; content text "Talos Linux on three nodes". Assert the page URL is unchanged (still `/`).
- `test_sources_row_opens_modal` — log in, find the `gitlab-compose.yaml` row link, click it; assert the modal is open with the yaml rendered in `<pre.doc-raw>` containing "gitlab/gitlab-ce:17.2.1-ce.0", mono font.
- `test_modal_closes_on_button_escape_and_backdrop` — open the modal, click `#doc-modal-close` → hidden; re-open, click backdrop → hidden; re-open, press Escape → hidden.
- `test_modal_focus_and_a11y` — on open, focus is inside `#doc-modal-content`; the panel has `role="dialog"` + `aria-modal="true"`; the close button has `aria-label`.
- `test_modal_xss_safe` — seed an XSS fixture doc, open via modal, assert the `<script>` shows as escaped text and no dialog fires (same as the phase-10 test but inside the modal).
- `test_standalone_page_still_works` — the phase-10 assertions for `/document.html` (title/content/format badge, not-found state, dark theme, no-CDN, a11y frame, `#doc-content .doc-md` ≤ 736px) are **kept** — the dedicated page must not regress.
- `test_modal_theme_and_no_cdn` — dark theme (document background `rgb(10,14,23)`), and the modal panel uses Phase-08 surface colour.
2. `tests/integration/test_api.py` — if the no-CDN test counts `<script>` tags on `index.html` / `sources.html`, bump the expected count to include `document-modal.js` (and confirm `document.html` count is unchanged).
3. Regressions to run green in isolation after the change: `test_document_back_navigation.py` (source chips now open a modal; verify the back-navigation story doesn't assert a new tab — if it does, adapt), `test_header_consistency.py` (new module scripts don't disturb the header), `test_smoke.py`.
4. `.agent/user_stories/document-modal.md` — write the story file mapping the modal behaviour to the E2E scenarios above.
## ASSUMPTIONS
- The phase-10 `expect_popup` calls are removed (no new tab); the modal opens in-page.
- The standalone page test is kept to guard the no-JS / direct-link fallback.
## Testing & Quality
- E2E: `tests/e2e/test_document_viewer.py` rewritten — the story gate, green **in isolation** (prereq `podman compose up -d db`).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_document_viewer.py -v --no-cov` green in isolation.
- [ ] `test_document_back_navigation.py`, `test_header_consistency.py`, `test_smoke.py` green in isolation (adapted if they asserted a new tab).
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL ≥ pre-change number.
- [ ] `uv run ruff check . && uv run pyright` clean.
+56 -14
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,11 +583,26 @@ 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 = "";
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
@@ -588,9 +617,19 @@ async function readSSE(response, onEvent) {
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);
+98 -60
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;
* 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).
* <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,6 +97,56 @@ function render(doc) {
}
}
/* ---------- /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} · Brain of Reese`;
}
function showNotFound() {
titleEl.textContent = "Document not found";
document.title = "Document not found · Brain of Reese";
@@ -119,19 +155,20 @@ function showNotFound() {
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.) */
/* 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. */
/* 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", () => {
@@ -160,3 +197,4 @@ async function load() {
}
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>
+1 -1
View File
@@ -158,7 +158,7 @@ def test_conversation_survives_reload(
chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md")
expect(chip).to_have_count(1)
expect(chip.first).to_have_attribute("href", CHIP_HREF)
expect(chip.first).to_have_attribute("target", "_blank")
expect(chip.first).not_to_have_attribute("target") # phase 26: modal, not a new tab
# The restore is read-only: storage still holds the same two messages.
assert [m["who"] for m in _stored_parsed(page)["messages"]] == ["user", "brain"]
+4 -4
View File
@@ -98,16 +98,16 @@ def test_on_topic_question_streams_grounded_answer(
# Grounded: a kubernetes.md source chip renders under the bubble
# (top-N docs can add more chips; the question's doc must be among them).
# Phase 10: chips open the document viewer in a new tab (encoded URL);
# phase 13 appends back=/ so the viewer's back button returns to chat.
# Phase 26: the chip opens the document in the SAME-PAGE modal — no new
# tab; the encoded href stays as the no-JS / context-menu escape hatch
# (phase 13's back=/ lets the viewer's back button return to chat).
chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md")
expect(chip).to_have_count(1)
expect(chip.first).to_contain_text("kubernetes.md")
expect(chip.first).to_have_attribute(
"href", "/document.html?source=docs&path=homelab%2Fkubernetes.md&back=%2F"
)
expect(chip.first).to_have_attribute("target", "_blank")
expect(chip.first).to_have_attribute("rel", "noopener")
expect(chip.first).not_to_have_attribute("target") # phase 26: modal, not a new tab
# Button recovers: enabled + "Send" (never stale).
expect(page.locator("#send-btn")).to_be_enabled()
+40 -31
View File
@@ -6,21 +6,26 @@ Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_document_back_navigation.py -v --no-cov
Both entry points (chat source chips, Sources table links) open the viewer
in a NEW tab, where there is no browser history — so the return target is
carried in the viewer URL: chat chips append ``&back=%2F`` (resolves to
"Chat"), Sources links omit the param (the viewer's default
``/sources.html`` applies → "Sources"). The viewer only honors
same-origin relative ``back`` values; everything else falls back to
``/sources.html``.
The viewer can be reached directly (no browser history to go back to),
so the return target is carried in the viewer URL: chat chips append
``&back=%2F`` (resolves to "Chat"), Sources links omit the param (the
viewer's default ``/sources.html`` applies → "Sources"). The viewer only
honors same-origin relative ``back`` values; everything else falls back
to ``/sources.html``.
Phase 26 adaptation: the chip/row-link LEFT click now opens the
document in the same-page modal — no new tab is spawned. The encoded
viewer URL survives as each link's ``href`` (the no-JS / context-menu
"open in new tab" escape hatch), so the back contract is asserted on
that exact href and verified by navigating to it directly.
Test → story mapping (Playwright Mapping Rule):
1. ``test_back_from_chat_returns_to_chat`` — question → source chip →
new tab with ``&back=%2F`` → back link href ``/`` labeled "Chat" →
click → the chat page.
2. ``test_back_from_sources_returns_to_sources`` — Sources table link →
new tab without a ``back`` param → back link href ``/sources.html``
labeled "Sources" → click → the Sources page.
1. ``test_back_from_chat_returns_to_chat`` — question → source chip href
(carries ``&back=%2F``) → viewer back link href ``/`` labeled "Chat"
→ click → the chat page.
2. ``test_back_from_sources_returns_to_sources`` — Sources table link
href (no ``back`` param) → back link href ``/sources.html`` labeled
"Sources" → click → the Sources page.
3. ``test_malicious_back_param_is_rejected`` — absolute,
protocol-relative, and ``javascript:`` ``back`` values all fall back
to ``/sources.html`` (labeled "Sources", navigable).
@@ -106,30 +111,33 @@ def test_back_from_chat_returns_to_chat(
chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md")
expect(chip).to_have_count(1, timeout=30_000)
# Chat chips carry back=/ (encoded %2F) so the viewer knows where home is.
# Chat chips carry back=/ (encoded %2F) so the viewer knows where
# home is. Phase 26: the left click opens the same-page modal (no
# target=_blank); this href is what the no-JS / context-menu "open
# in a new tab" path reaches, so the back contract rides on it.
expect(chip.first).to_have_attribute(
"href", f"/document.html?source={DOC_SOURCE}&path={DOC_PATH}&back=%2F"
)
expect(chip.first).not_to_have_attribute("target") # phase 26: modal, not a new tab
with page.expect_popup() as popup_info:
chip.first.click()
viewer = popup_info.value
expect(viewer).to_have_url(
# The exact href asserted above (the no-JS / new-tab escape hatch).
page.goto(f"{app_url}/document.html?source={DOC_SOURCE}&path={DOC_PATH}&back=%2F")
expect(page).to_have_url(
re.compile(
re.escape(f"{app_url}/document.html?source={DOC_SOURCE}&path={DOC_PATH}&back=%2F")
)
)
# The cited document actually rendered (this is the viewer, not an error).
expect(viewer.locator("#doc-title")).to_have_text(DOC_TITLE)
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE)
# Back link resolved to the chat page, labeled "Chat".
back = viewer.locator("#doc-back")
back = page.locator("#doc-back")
expect(back).to_have_attribute("href", "/")
expect(back).to_have_text("Chat")
# Click: deterministic anchor navigation back to the chat page.
back.click()
expect(viewer).to_have_url(f"{app_url}/")
expect(viewer.locator("#composer")).to_be_visible()
expect(page).to_have_url(f"{app_url}/")
expect(page.locator("#composer")).to_be_visible()
# ---------------------------------------------------------------------------
@@ -148,24 +156,25 @@ def test_back_from_sources_returns_to_sources(
link = row.locator("td:nth-child(2) a.doc-link")
expect(link).to_have_count(1)
# Sources links carry NO back param — the viewer's default target
# (/sources.html) applies.
# (/sources.html) applies. Phase 26: left click opens the modal;
# the href (no back param) is the no-JS / new-tab escape hatch.
expect(link).to_have_attribute(
"href", f"/document.html?source={DOC_SOURCE}&path={DOC_PATH}"
)
expect(link).not_to_have_attribute("target") # phase 26: modal, not a new tab
with page.expect_popup() as popup_info:
link.click()
viewer = popup_info.value
assert "back=" not in viewer.url, f"unexpected back param: {viewer.url}"
expect(viewer.locator("#doc-title")).to_have_text(DOC_TITLE)
# The exact href asserted above — no back param in the URL.
page.goto(f"{app_url}/document.html?source={DOC_SOURCE}&path={DOC_PATH}")
assert "back=" not in page.url, f"unexpected back param: {page.url}"
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE)
# Back link kept the default target, labeled "Sources".
back = viewer.locator("#doc-back")
back = page.locator("#doc-back")
expect(back).to_have_attribute("href", "/sources.html")
expect(back).to_have_text("Sources")
back.click()
expect(viewer).to_have_url(f"{app_url}/sources.html")
expect(viewer.locator("#docs-table")).to_be_visible()
expect(page).to_have_url(f"{app_url}/sources.html")
expect(page.locator("#docs-table")).to_be_visible()
# ---------------------------------------------------------------------------
+237 -82
View File
@@ -1,25 +1,32 @@
"""Phase 10 E2E (Playwright): the clickable document viewer.
"""Phase 26 E2E (Playwright): documents open in the almost-fullscreen
modal — not in a new page.
Story: ``.agent/user_stories/document-viewer.md``
Story: ``.agent/user_stories/document-modal.md``
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_document_viewer.py -v --no-cov
Seeding reuses the real importer against ``tests/fixtures/docs/`` with the
deterministic mock embeddings (same pattern as the earlier story suites).
deterministic mock embeddings (same harness as the phase-10 suite — only
the assertions changed: chips/row links now open the SAME-PAGE modal,
no ``expect_popup``).
Test → story mapping (Playwright Mapping Rule):
1. ``test_source_chip_opens_document`` — chip → NEW TAB → viewer with
title + known content string + format badge.
2. ``test_sources_row_links_to_viewer`` — Sources path link (yaml
fixture) → viewer with raw content in a ``pre``.
3. ``test_markdown_renders_and_stays_xss_safe`` — md fixture containing
``<script>alert(1)</script>`` renders as visible escaped text (no
execution).
4. ``test_missing_doc_shows_not_found`` — unknown doc → not-found
state + Sources link; no console crash.
5. ``test_viewer_theme_and_no_cdn`` — dark theme + every
``script[src]`` / ``link[href]`` local or ``data:`` + a11y frame.
1. ``test_source_chip_opens_modal`` — chat chip → modal opens in-page
(NO new tab, URL unchanged), title + ``.doc-md`` content + meta row.
2. ``test_sources_row_opens_modal`` — Sources path link → modal, yaml in
``<pre.doc-raw>``, mono font, URL unchanged.
3. ``test_modal_closes_on_button_escape_and_backdrop`` — close via
``#doc-modal-close``, via backdrop click, via ``Escape``.
4. ``test_modal_focus_and_a11y`` — ``role="dialog"`` + ``aria-modal``,
focus inside the panel on open, close button has an ``aria-label``.
5. ``test_modal_xss_safe`` — hostile md document opened through the modal
renders as escaped text; no dialog fires.
6. ``test_standalone_page_still_works`` — the dedicated ``/document.html``
page keeps its phase-10 contract (title/content/badges, not-found,
dark theme, no-CDN, a11y frame, ≤736px md column).
7. ``test_modal_theme_and_no_cdn`` — dark page background, the panel on
the Phase-08 surface colour, every asset same-origin or ``data:``.
"""
from __future__ import annotations
@@ -83,60 +90,84 @@ def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
return _run_in_thread(_import_fixtures(mock_port))
def _ask_for_chip(page: Page, app_url: str) -> Any:
"""Drive one chat turn and return the kubernetes.md source chip."""
page.goto(app_url)
page.fill("#message-input", QUESTION)
page.click("#send-btn")
chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md")
expect(chip).to_have_count(1, timeout=30_000)
return chip
def _assert_closed(page: Page) -> None:
"""The modal is fully closed: the hidden attribute is back and the
overlay is gone from view."""
expect(page.locator("#doc-modal")).to_have_attribute("hidden", "")
expect(page.locator(".doc-modal")).not_to_be_visible()
# ---------------------------------------------------------------------------
# 1. Chat source chip → new tab → full document
# 1. Chat source chip → SAME-PAGE modal (no new tab)
# ---------------------------------------------------------------------------
def test_source_chip_opens_document(
def test_source_chip_opens_modal(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
page.fill("#message-input", QUESTION)
page.click("#send-btn")
chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md")
expect(chip).to_have_count(1, timeout=30_000)
# New-tab contract: same-origin viewer URL, all query values encoded
# (the path's slashes come out as %2F — exactly why encoding matters),
# plus back=/ (phase 13) so the viewer's back button returns to chat.
chip = _ask_for_chip(page, app_url)
# The encoded viewer URL stays as the no-JS / context-menu escape
# hatch — but phase 26 removed target=_blank: the left click is
# intercepted and opens the modal in place.
expect(chip.first).to_have_attribute(
"href", "/document.html?source=docs&path=homelab%2Fkubernetes.md&back=%2F"
)
expect(chip.first).to_have_attribute("target", "_blank")
expect(chip.first).to_have_attribute("rel", "noopener")
expect(chip.first).not_to_have_attribute("target")
with page.expect_popup() as popup_info:
before = len(page.context.pages)
chip.first.click()
viewer = popup_info.value
expect(viewer).to_have_url(
re.compile(
re.escape(
f"{app_url}/document.html?source=docs&path=homelab%2Fkubernetes.md&back=%2F"
# No new tab: the click must not have spawned a page.
assert len(page.context.pages) == before, "clicking a chip must not open a new tab"
# The almost-fullscreen modal becomes visible (hidden attribute gone).
expect(page.locator("#doc-modal")).not_to_have_attribute("hidden")
expect(page.locator(".doc-modal")).to_be_visible()
# "Almost-fullscreen": the panel is min(1100px, 96vw) × 92vh, centered
# (at a 1280px viewport the 1100px cap wins over 96vw = 1228.8px).
box = page.locator("#doc-modal-panel").bounding_box()
assert box is not None, "modal panel not rendered"
expected_w = min(1100, 0.96 * 1280)
expected_h = 0.92 * 800
assert abs(box["width"] - expected_w) < 2, f"panel width {box['width']} (want ~{expected_w})"
assert abs(box["height"] - expected_h) < 2, f"panel height {box['height']} (want ~{expected_h})"
# Same content the /document.html page renders: title, markdown in
# the centered .doc-md column (the modal's content target is
# #doc-modal-content — the modal variant of the page's #doc-content).
expect(page.locator("#doc-modal-title")).to_have_text("Kubernetes Homelab Cluster")
expect(page.locator("#doc-modal-content .doc-md")).to_have_count(1)
expect(page.locator("#doc-modal-content")).to_contain_text("Talos Linux on three nodes")
# Meta row mirrors the viewer: source · format · mono path · indexed · chunks.
expect(page.locator("#doc-modal-meta .doc-source-badge")).to_have_text("docs")
expect(page.locator("#doc-modal-meta .format-badge")).to_have_text("md")
expect(page.locator("#doc-modal-meta .doc-path")).to_have_text("homelab/kubernetes.md")
expect(page.locator("#doc-modal-meta .doc-indexed")).to_contain_text("Indexed")
assert re.fullmatch(
r"\d+ chunks?", page.locator("#doc-modal-meta .doc-chunks").inner_text()
)
)
)
expect(viewer.locator("#doc-title")).to_have_text("Kubernetes Homelab Cluster")
# Meta row: source badge · format badge · mono path · indexed · chunks.
expect(viewer.locator("#doc-meta .doc-source-badge")).to_have_text("docs")
expect(viewer.locator("#doc-meta .format-badge")).to_have_text("md")
expect(viewer.locator("#doc-meta .doc-path")).to_have_text("homelab/kubernetes.md")
expect(viewer.locator("#doc-meta .doc-indexed")).to_contain_text("Indexed")
assert re.fullmatch(r"\d+ chunks?", viewer.locator("#doc-meta .doc-chunks").inner_text())
# Full document, rendered markdown in the centered column (not a pre).
expect(viewer.locator("#doc-content .doc-md")).to_have_count(1)
expect(viewer.locator("#doc-content")).to_contain_text("Talos Linux on three nodes")
# Still the chat page: no navigation happened.
assert page.url == app_url + "/", f"navigated away: {page.url}"
# ---------------------------------------------------------------------------
# 2. Sources table path link → viewer (yaml → raw pre)
# 2. Sources table path link → same-page modal (yaml → raw pre)
# ---------------------------------------------------------------------------
def test_sources_row_links_to_viewer(
def test_sources_row_opens_modal(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
@@ -146,34 +177,119 @@ def test_sources_row_links_to_viewer(
expect(row).to_have_count(1)
link = row.locator("td:nth-child(2) a.doc-link")
expect(link).to_have_count(1)
# Encoded URL: the slashes in the path value come out as %2F.
# Encoded URL kept as the escape hatch (slashes come out as %2F);
# no target=_blank any more.
expect(link).to_have_attribute(
"href",
"/document.html?source=docs&path=homelab%2Fcontainer_gitlab%2Fgitlab-compose.yaml",
)
expect(link).to_have_attribute("target", "_blank")
expect(link).to_have_attribute("rel", "noopener")
expect(link).not_to_have_attribute("target")
expect(link).to_have_attribute("title", "homelab/container_gitlab/gitlab-compose.yaml")
with page.expect_popup() as popup_info:
before = len(page.context.pages)
link.click()
viewer = popup_info.value
expect(viewer.locator("#doc-title")).to_have_text("gitlab-compose")
expect(viewer.locator("#doc-meta .format-badge")).to_have_text("yaml")
assert len(page.context.pages) == before, "clicking a row link must not open a new tab"
expect(page.locator(".doc-modal")).to_be_visible()
expect(page.locator("#doc-modal-title")).to_have_text("gitlab-compose")
expect(page.locator("#doc-modal-meta .format-badge")).to_have_text("yaml")
# Non-markdown formats render as escaped monospace text in a pre.
pre = viewer.locator("#doc-content pre.doc-raw")
pre = page.locator("#doc-modal-content pre.doc-raw")
expect(pre).to_have_count(1)
expect(pre).to_contain_text("gitlab/gitlab-ce:17.2.1-ce.0")
font = pre.evaluate("el => getComputedStyle(el).fontFamily")
assert "mono" in font
# Still on the Sources page: no navigation happened.
assert page.url == app_url + "/sources.html", f"navigated away: {page.url}"
# ---------------------------------------------------------------------------
# 3. Markdown renders through the shared renderer and stays XSS-safe
# 3. Close on button, backdrop, and Escape
# ---------------------------------------------------------------------------
def test_markdown_renders_and_stays_xss_safe(
def test_modal_closes_on_button_escape_and_backdrop(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
chip = _ask_for_chip(page, app_url)
def open_and_loaded() -> None:
chip.first.click()
expect(page.locator("#doc-modal-title")).to_have_text("Kubernetes Homelab Cluster")
# 1) The close button.
open_and_loaded()
page.click("#doc-modal-close")
_assert_closed(page)
# 2) The backdrop — a point outside the centered 96vw × 92vh panel
# (panel starts at 4vh from the top / 2vw from the edge).
open_and_loaded()
page.locator("#doc-modal-backdrop").click(position={"x": 5, "y": 5})
_assert_closed(page)
# 3) Escape — the capture is document-level, so it works from any
# focus position inside (or outside) the panel.
open_and_loaded()
page.keyboard.press("Escape")
_assert_closed(page)
# After closing, the page behind is untouched: chat is still there.
assert page.url == app_url + "/"
expect(page.locator("#composer")).to_be_visible()
# ---------------------------------------------------------------------------
# 4. Focus management + dialog a11y frame
# ---------------------------------------------------------------------------
def test_modal_focus_and_a11y(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
chip = _ask_for_chip(page, app_url)
chip.first.click()
expect(page.locator("#doc-modal-title")).to_have_text("Kubernetes Homelab Cluster")
# The panel is a proper modal dialog, labelled by its title.
panel = page.locator("#doc-modal-panel")
expect(panel).to_have_attribute("role", "dialog")
expect(panel).to_have_attribute("aria-modal", "true")
expect(panel).to_have_attribute("aria-labelledby", "doc-modal-title")
# On open, focus moves into the dialog's content target.
focus_id = page.evaluate("() => document.activeElement && document.activeElement.id")
assert focus_id == "doc-modal-content", f"focus {focus_id!r} did not move into the modal"
# The close control carries an accessible name (icon-only button).
expect(page.locator("#doc-modal-close")).to_have_attribute("aria-label", "Close document")
# The "Full page" escape hatch is rebuilt to the same encoded viewer
# URL (no back param — the dedicated page's own default applies).
expect(page.locator("#doc-modal-open")).to_be_visible()
expect(page.locator("#doc-modal-open")).to_have_attribute(
"href", "/document.html?source=docs&path=homelab%2Fkubernetes.md"
)
# Closing returns focus to the triggering control.
page.keyboard.press("Escape")
_assert_closed(page)
focus_id = page.evaluate("() => document.activeElement && document.activeElement.className")
assert "source-chip" in (focus_id or ""), f"focus {focus_id!r} did not return to the chip"
# ---------------------------------------------------------------------------
# 5. Modal rendering stays XSS-safe (hostile md, opened via the modal)
# ---------------------------------------------------------------------------
def test_modal_xss_safe(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
@@ -200,28 +316,46 @@ def test_markdown_renders_and_stays_xss_safe(
d.dismiss()
page.on("dialog", _catch_dialog)
page.goto(f"{app_url}/document.html?source=docs&path=notes%2Fxss-fixture.md")
expect(page.locator("#doc-title")).to_have_text("Xss Fixture")
# The Sources table lists every indexed document — the admin entry
# point into the modal for a doc the chat never cited.
login(page, app_url)
row = page.locator("#docs-tbody tr", has_text="xss-fixture.md")
expect(row).to_have_count(1)
row.locator("td:nth-child(2) a.doc-link").click()
expect(page.locator(".doc-modal")).to_be_visible()
expect(page.locator("#doc-modal-title")).to_have_text("Xss Fixture")
# The tag shows up as VISIBLE, ESCAPED text — rendered, never executed.
expect(page.locator("#doc-content")).to_contain_text("<script>alert(1)</script>")
expect(page.locator("#doc-content")).to_contain_text("XSS-FIXTURE-MARKER")
assert page.locator("#doc-content script").count() == 0, "hostile script became live HTML"
expect(page.locator("#doc-modal-content")).to_contain_text("<script>alert(1)</script>")
expect(page.locator("#doc-modal-content")).to_contain_text("XSS-FIXTURE-MARKER")
assert page.locator("#doc-modal-content script").count() == 0, "hostile script became live HTML"
assert dialogs == [], f"dialog fired — script executed: {dialogs}"
# ---------------------------------------------------------------------------
# 4. Missing document → designed not-found state, no console crash
# 6. The dedicated /document.html page keeps its phase-10 contract
# ---------------------------------------------------------------------------
def test_missing_doc_shows_not_found(
def test_standalone_page_still_works(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
errors: list[str] = []
page.on("pageerror", lambda e: errors.append(str(e)))
# Direct link renders exactly as before (phase 10): title, meta row,
# markdown in the centered column.
page.goto(f"{app_url}/document.html?source=docs&path=homelab%2Fkubernetes.md")
expect(page.locator("#doc-title")).to_have_text("Kubernetes Homelab Cluster")
expect(page.locator("#doc-meta .doc-source-badge")).to_have_text("docs")
expect(page.locator("#doc-meta .format-badge")).to_have_text("md")
expect(page.locator("#doc-content .doc-md")).to_have_count(1)
expect(page.locator("#doc-content")).to_contain_text("Talos Linux on three nodes")
# Not-found state: an unknown pair AND missing params — no console
# crash, the designed card with the Sources link.
page.goto(f"{app_url}/document.html?source=docs&path=definitely/not/here.md")
expect(page.locator("#doc-title")).to_have_text("Document not found")
card = page.locator("#doc-not-found")
@@ -229,29 +363,15 @@ def test_missing_doc_shows_not_found(
expect(card).to_contain_text("Document not found")
expect(card.locator("a.doc-open-sources")).to_have_attribute("href", "/sources.html")
expect(page.locator("#doc-content")).to_be_empty()
# Missing params → the same designed state (no fetch, no crash).
page.goto(f"{app_url}/document.html")
expect(page.locator("#doc-not-found")).to_be_visible()
assert errors == [], f"console crashes: {errors}"
# ---------------------------------------------------------------------------
# 5. Dark theme + all assets local + a11y frame
# ---------------------------------------------------------------------------
def test_viewer_theme_and_no_cdn(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None:
_reset_db(mock_llm, seed=True)
# Dark theme + all assets local + a11y frame + capped md column.
page.goto(f"{app_url}/document.html?source=docs&path=homelab%2Fkubernetes.md")
expect(page.locator("#doc-content .doc-md")).not_to_be_empty()
# Dark theme inherited from phase 08 (same sampling as that story).
bg = page.evaluate("() => getComputedStyle(document.documentElement).backgroundColor")
assert bg == "rgb(10, 14, 23)"
# No-CDN: every script/link reference is same-origin or a data: URI.
refs = page.evaluate(
"""() => [...document.querySelectorAll("script[src], link[href]")]
.map((el) => el.src || el.href)"""
@@ -260,8 +380,6 @@ def test_viewer_theme_and_no_cdn(page: Page, app_url: str, mock_llm: int, db_rea
for ref in refs:
assert ref.startswith(app_url) or ref.startswith("data:"), f"non-local: {ref}"
# A11y frame: landmarks, skip link, aria-live around the load→content
# swap, and focus moved to main on load.
expect(page.locator("header.doc-header")).to_have_count(1)
expect(page.locator("main#main")).to_have_count(1)
expect(page.locator("footer.app-footer")).to_have_count(1)
@@ -272,3 +390,40 @@ def test_viewer_theme_and_no_cdn(page: Page, app_url: str, mock_llm: int, db_rea
# Markdown column centered and capped at 46rem (736px at 16px root).
box = page.locator("#doc-content .doc-md").bounding_box()
assert box is not None and box["width"] <= 736 + 1
assert errors == [], f"console crashes: {errors}"
# ---------------------------------------------------------------------------
# 7. Modal theme + no CDN on the touched page
# ---------------------------------------------------------------------------
def test_modal_theme_and_no_cdn(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
chip = _ask_for_chip(page, app_url)
chip.first.click()
expect(page.locator("#doc-modal-title")).to_have_text("Kubernetes Homelab Cluster")
expect(page.locator("#doc-modal-content .doc-md")).not_to_be_empty()
# Dark theme (phase 08): the page background is untouched, and the
# modal panel sits on the Phase-08 surface colour (#121a2e).
bg = page.evaluate("() => getComputedStyle(document.documentElement).backgroundColor")
assert bg == "rgb(10, 14, 23)"
surface = page.evaluate(
"() => getComputedStyle(document.querySelector('.doc-modal-panel')).backgroundColor"
)
assert surface == "rgb(18, 26, 46)", f"panel not on the Phase-08 surface: {surface}"
# No-CDN: every script/link reference on the chat page (the touched
# page) is same-origin or a data: URI — the modal adds no assets.
refs = page.evaluate(
"""() => [...document.querySelectorAll("script[src], link[href]")]
.map((el) => el.src || el.href)"""
)
assert refs, "expected local asset references on the chat page"
for ref in refs:
assert ref.startswith(app_url) or ref.startswith("data:"), f"non-local: {ref}"
+2
View File
@@ -77,6 +77,7 @@ def test_styles_and_js_served(client) -> None:
assert client.get("/assets/markdown.js").status_code == 200 # phase 10: shared renderer
assert client.get("/assets/document.js").status_code == 200 # phase 10: viewer page
assert client.get("/assets/login.js").status_code == 200 # phase 16: login page
assert client.get("/assets/document-modal.js").status_code == 200 # phase 26: modal module
# Emoji code points banned from UI chrome (phase 08): the pictograph
@@ -112,6 +113,7 @@ def _find_emoji(text: str) -> list[str]:
"/assets/markdown.js",
"/assets/document.js",
"/assets/login.js", # phase 16
"/assets/document-modal.js", # phase 26: the document modal module
"/assets/styles.css",
],
)
+149 -8
View File
@@ -13,8 +13,11 @@ Frontend side:
spaces/slashes) executed under node when available, plus source pins that
run everywhere;
* the shared-renderer extraction — ``markdown.js`` holds the renderer,
loaded by BOTH pages via a relative ``<script src>`` before the module
scripts.
loaded by the pages that use it via a relative ``<script src>`` before
the module scripts;
* phase 26 — the shared ``renderDocument`` export in ``document.js``, the
import-safe page guard, the modal skeleton on chat + Sources, and the
modal module's close/focus/URL contract.
"""
from __future__ import annotations
@@ -37,6 +40,9 @@ APP_JS = FRONTEND / "assets" / "app.js"
SOURCES_JS = FRONTEND / "assets" / "sources.js"
DOCUMENT_JS = FRONTEND / "assets" / "document.js"
MARKDOWN_JS = FRONTEND / "assets" / "markdown.js"
MODAL_JS = FRONTEND / "assets" / "document-modal.js" # phase 26: the modal owner
INDEX_HTML = FRONTEND / "index.html"
SOURCES_HTML = FRONTEND / "sources.html"
HAVE_NODE = shutil.which("node") is not None
@@ -160,30 +166,38 @@ def test_content_requires_both_params() -> None:
def test_viewer_url_builder_present_in_chat_and_sources() -> None:
"""Both entry points (chat chips, Sources rows) build the same
encoded viewer URL and open it in a new tab with rel=noopener.
encoded viewer URL — kept as each link's ``href`` (no-JS /
context-menu escape hatch to the dedicated viewer).
Phase 13: the chat builder additionally carries ``back=/`` (encoded
%2F) so the viewer's back button returns to the chat; Sources links
intentionally omit the param (the viewer's /sources.html default)."""
intentionally omit the param (the viewer's /sources.html default).
Phase 26: the left-click no longer opens a new tab — it is
intercepted (preventDefault) and routed to openDocumentModal from
the shared modal module; no ``target="_blank"" survives on either
entry point."""
for name, js in (("app.js", _read(APP_JS)), ("sources.js", _read(SOURCES_JS))):
assert '"/document.html?source=" + encodeURIComponent(' in js, name
assert '"&path=" + encodeURIComponent(' in js, name
assert 'target = "_blank"' not in js, f"{name}: phase 26 — no new tabs"
app_js = _read(APP_JS)
# Chat: 3-arg builder with back defaulting to the chat page.
assert 'function documentUrl(source, path, back = "/")' in app_js
assert '"&back=" + encodeURIComponent(back)' in app_js
assert 'chip.href = documentUrl(s.source, s.path, "/")' in app_js
assert 'chip.target = "_blank"' in app_js
assert 'chip.rel = "noopener"' in app_js
assert 'chip.addEventListener("click"' in app_js
assert "e.preventDefault()" in app_js
assert "openDocumentModal(s.source, s.path, chip)" in app_js
sources_js = _read(SOURCES_JS)
# Sources: unchanged 2-arg builder — no back param in the URL.
assert "function documentUrl(source, path)" in sources_js
assert 'link.className = "doc-link"' in sources_js
assert "link.href = documentUrl(d.source, d.path)" in sources_js
assert 'link.target = "_blank"' in sources_js
assert 'link.rel = "noopener"' in sources_js
assert 'link.addEventListener("click"' in sources_js
assert "openDocumentModal(d.source, d.path, link)" in sources_js
# The full path stays the hover name on the ellipsized cell AND the link.
assert "pathTd.title = d.path" in sources_js
assert "link.title = d.path" in sources_js
@@ -269,6 +283,133 @@ def test_markdown_renderer_stays_xss_safe_and_unchanged() -> None:
assert "<h3>Title</h3>" in html
# ---------------------------------------------------------------------------
# Phase 26 — shared renderDocument + the document modal wiring
# ---------------------------------------------------------------------------
def test_render_document_exported_and_modal_imports_it() -> None:
"""Phase 26: document.js EXPORTS renderDocument(doc, { … }) — the
exact renderer the standalone page and the modal share (no drift).
The modal module imports it relatively, and BOTH page scripts import
the modal module relatively — no direct <script> tag (the header.js
single-evaluation design: esbuild inlines it into the page bundle,
one module instance per page)."""
doc_js = _read(DOCUMENT_JS)
# Task 03 signature: the page passes its #doc-title / #doc-meta /
# #doc-content elements under exactly these names.
assert "export function renderDocument(doc, { titleEl, metaEl, contentEl })" in doc_js, (
"document.js must export renderDocument(doc, { titleEl, metaEl, contentEl })"
)
# The standalone page renders through the SAME shared function with its
# own page elements (no second renderer copy).
assert "renderDocument(doc, { titleEl, metaEl, contentEl })" in doc_js
modal_js = _read(MODAL_JS)
assert 'from "./document.js"' in modal_js
assert "export function openDocumentModal(" in modal_js
assert "export function closeDocumentModal(" in modal_js
for name, js in (("app.js", _read(APP_JS)), ("sources.js", _read(SOURCES_JS))):
assert 'from "./document-modal.js"' in js, (
f"{name}: must import the modal module relatively"
)
for page in (INDEX_HTML, SOURCES_HTML):
text = _read(page)
assert not re.search(r"<script[^>]*document-modal\.js", text), (
f"{page.name}: no direct document-modal.js <script> tag "
"(single-evaluation design — the page script imports it)"
)
def test_document_js_page_init_is_import_safe() -> None:
"""Phase 26: the /document.html-specific init (back-link resolution,
whoami, content load) runs ONLY when #doc-title exists — the modal
module's `import { renderDocument } from "./document.js"` on the
chat/sources pages must have no side effects."""
js = _read(DOCUMENT_JS)
guard = js.find('querySelector("#doc-title")')
back_href = js.find("backLink.href = backTarget")
load_call = js.rfind("load();")
assert 0 < guard < back_href < load_call, (
"the viewer-page init must sit inside the #doc-title guard "
"(after it, and load() must be the guarded entry point)"
)
def test_both_pages_carry_the_modal_skeleton() -> None:
"""Phase 26: chat AND Sources ship the same modal skeleton (the
task-01 markup) — the a11y frame included: role=dialog +
aria-modal, a labelled close control, a focusable content target
(tabindex=-1), and a role=status announcer. Hidden by default —
inert until JS opens it."""
for page in (INDEX_HTML, SOURCES_HTML):
text = _read(page)
assert '<div class="doc-modal" id="doc-modal" hidden>' in text, page.name
assert 'id="doc-modal-backdrop"' in text, page.name
assert 'id="doc-modal-panel"' in text, page.name
assert 'role="dialog"' in text and 'aria-modal="true"' in text, page.name
assert 'id="doc-modal-title"' in text, page.name
assert 'id="doc-modal-meta"' in text, page.name
assert 'id="doc-modal-desc"' in text, page.name
assert 'id="doc-modal-open"' in text, page.name
assert re.search(r'id="doc-modal-content"[^>]*tabindex="-1"', text), page.name
assert re.search(
r'id="doc-modal-close"[^>]*aria-label="Close document"', text
), page.name
def test_sources_page_loads_markdown_before_its_module() -> None:
"""Phase 26: the modal renders md documents on the Sources page too —
so sources.html loads the classic markdown.js (global renderMarkdown)
via a relative <script src> BEFORE its module script, exactly like
index.html does."""
html = _read(SOURCES_HTML)
assert re.search(r'<script src="assets/markdown\.js"></script>', html)
assert html.index('src="assets/markdown.js"') < html.index('type="module"'), (
"sources.html: markdown.js must load before the module script"
)
def test_modal_close_contract_pins() -> None:
"""Phase 26: the modal closes on the close button, on backdrop
click, and on Escape (captured document-level, so it works from any
focus position); focus returns to the triggering control
(best-effort); the fetch goes to the stateless content endpoint with
the same percent-encoding the page uses, and success renders through
the shared renderDocument; the "Full page" link is rebuilt on open."""
js = _read(MODAL_JS)
assert 'e.key === "Escape"' in js
assert 'addEventListener("keydown"' in js
assert "backdropEl.addEventListener(\"click\", closeDocumentModal)" in js
assert "closeEl.addEventListener(\"click\", closeDocumentModal)" in js
assert "triggerEl.focus" in js # best-effort focus restore
assert '"/api/documents/content?source=" + encodeURIComponent(' in js
assert '"&path=" + encodeURIComponent(' in js
# The shared renderer (not a copy), called with the modal's own
# #doc-modal-title / #doc-modal-meta / #doc-modal-content elements.
assert "renderDocument(doc, { titleEl, metaEl, contentEl })" in js
assert 'openEl.href = fullPageUrl(source, path)' in js
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_modal_url_builders_encode_like_the_page() -> None:
"""Behavioral check (node) of the modal's own URL builders: the
content fetch and the "Full page" href must come out percent-encoded
exactly like the page's builders (slashes/spaces in real paths)."""
js = _read(MODAL_JS)
content_fn = _extract_function(js, "contentUrl")
full_fn = _extract_function(js, "fullPageUrl")
out = _run_node(
content_fn
+ full_fn
+ "\nconsole.log(contentUrl('Homelab', 'notes/my file.yaml'));\n"
+ "console.log(fullPageUrl('H omelab', 'a/b.md'));"
)
assert out.splitlines() == [
"/api/documents/content?source=Homelab&path=notes%2Fmy%20file.yaml",
"/document.html?source=H%20omelab&path=a%2Fb.md",
]
def test_viewer_js_rendering_contracts() -> None:
"""document.js: raw formats go in via textContent (never parsed as
HTML), markdown via the shared renderer, 404 → designed not-found