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:
@@ -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
@@ -60,10 +60,20 @@
|
|||||||
* (thinking or answer). scrollReveal(wrap) is the single scroll gate;
|
* (thinking or answer). scrollReveal(wrap) is the single scroll gate;
|
||||||
* `force` is reserved for the one-shot phase-14 restore landing.
|
* `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.
|
* All DOM ids match frontend/index.html.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { fetchIsAdmin, initSharedHeader } from "./header.js";
|
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 messagesEl = document.querySelector("#messages");
|
||||||
const emptyState = document.querySelector("#empty-state");
|
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`) ----------
|
/* ---------- document viewer link (phase 10; phase 13 adds `back`) ----------
|
||||||
* Every cited document opens in the viewer, in a NEW tab. All query
|
* The href a source chip carries: the dedicated viewer (no-JS /
|
||||||
* values are percent-encoded: real paths contain slashes and sometimes
|
* context-menu escape hatch). Phase 26: the chip's left-click is
|
||||||
* spaces, which would otherwise corrupt the query string. `back` tells the
|
* intercepted and the document opens in the same-page modal instead
|
||||||
* viewer which page to return to when its back button is clicked — the
|
* (document-modal.js) — this URL is also what the modal's "Full page"
|
||||||
* chips live in the chat, so chat passes "/" (the viewer validates it:
|
* link points at. All query values are percent-encoded: real paths
|
||||||
* only same-origin relative URLs are honored; Sources links omit it and
|
* contain slashes and sometimes spaces, which would otherwise corrupt
|
||||||
* get the viewer's /sources.html default). (The renderer
|
* the query string. `back` tells the viewer which page to return to
|
||||||
* renderMarkdown/escapeHtml now lives in assets/markdown.js — a classic
|
* when its back button is clicked — the chips live in the chat, so chat
|
||||||
* script loaded by index.html and document.html before these modules.) */
|
* 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 = "/") {
|
export function documentUrl(source, path, back = "/") {
|
||||||
let url =
|
let url =
|
||||||
"/document.html?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
|
"/document.html?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
|
||||||
@@ -569,11 +583,26 @@ function autoGrow() {
|
|||||||
|
|
||||||
/* ---------- chat turn (SSE streaming, PLAN §4) ---------- */
|
/* ---------- 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. */
|
/* Parse an SSE response body into JSON events. */
|
||||||
async function readSSE(response, onEvent) {
|
async function readSSE(response, onEvent) {
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buf = "";
|
let buf = "";
|
||||||
|
try {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
@@ -588,9 +617,19 @@ async function readSSE(response, onEvent) {
|
|||||||
onEvent(JSON.parse(payload));
|
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) {
|
function appendSources(wrap, sources) {
|
||||||
if (!sources || !sources.length) return;
|
if (!sources || !sources.length) return;
|
||||||
const body = wrap.querySelector(".msg-body");
|
const body = wrap.querySelector(".msg-body");
|
||||||
@@ -604,8 +643,11 @@ function appendSources(wrap, sources) {
|
|||||||
chip.className = "source-chip";
|
chip.className = "source-chip";
|
||||||
chip.setAttribute("role", "listitem");
|
chip.setAttribute("role", "listitem");
|
||||||
chip.href = documentUrl(s.source, s.path, "/"); // back → the chat page
|
chip.href = documentUrl(s.source, s.path, "/"); // back → the chat page
|
||||||
chip.target = "_blank"; // open the full document in a new tab
|
chip.addEventListener("click", (e) => {
|
||||||
chip.rel = "noopener";
|
e.preventDefault(); // no new tab (phase 26) — the modal takes over
|
||||||
|
e.stopPropagation();
|
||||||
|
openDocumentModal(s.source, s.path, chip);
|
||||||
|
});
|
||||||
chip.textContent = label;
|
chip.textContent = label;
|
||||||
chip.title = label;
|
chip.title = label;
|
||||||
meta.appendChild(chip);
|
meta.appendChild(chip);
|
||||||
@@ -862,7 +904,7 @@ async function handleSend(e) {
|
|||||||
setUiState(UI_STATE.thinking);
|
setUiState(UI_STATE.thinking);
|
||||||
armTurnTimeout(() => {
|
armTurnTimeout(() => {
|
||||||
aborted = true;
|
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.");
|
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.
|
// turn-local, so a page reload mid-stream leaves a usable composer.
|
||||||
clearTurnTimeout();
|
clearTurnTimeout();
|
||||||
stopThinkingClock();
|
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);
|
if (uiState !== UI_STATE.idle) setUiState(UI_STATE.idle);
|
||||||
// Phase 18: focus back for the next question, but never move the
|
// Phase 18: focus back for the next question, but never move the
|
||||||
// viewport — a user reading earlier content stays where they are.
|
// viewport — a user reading earlier content stays where they are.
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
/* Brain of Reese — document modal (phase 26, task 02).
|
||||||
|
*
|
||||||
|
* "New documents should open in an almost-fullscreen modal, not in a new
|
||||||
|
* page" (TODO.md L4). This module is the SINGLE owner of the modal (the
|
||||||
|
* way header.js owns the shared header): the chat page (app.js source
|
||||||
|
* chips) and the Sources page (sources.js table links) each import
|
||||||
|
* openDocumentModal(...) from here — one implementation, no duplicate
|
||||||
|
* module instance, no direct <script> tag (esbuild inlines it into the
|
||||||
|
* page bundle, same single-evaluation design as header.js).
|
||||||
|
*
|
||||||
|
* Contract:
|
||||||
|
* • openDocumentModal(source, path, triggerEl) — shows the #doc-modal
|
||||||
|
* overlay (the phase-26 task-01 skeleton) in its loading state,
|
||||||
|
* moves focus into #doc-modal-content (tabindex="-1" — programmatic
|
||||||
|
* focus target), fetches GET /api/documents/content (the SAME
|
||||||
|
* stateless endpoint the /document.html page uses — identical
|
||||||
|
* percent-encoding), and renders through document.js's shared
|
||||||
|
* renderDocument: md/markdown → .doc-md via the escape-first
|
||||||
|
* renderer, other formats → <pre class="doc-raw"> via textContent.
|
||||||
|
* A !ok / network failure renders a short "document not found" line
|
||||||
|
* in the content area.
|
||||||
|
* • closeDocumentModal() — hides the overlay, removes the Escape/Tab
|
||||||
|
* capture, and restores focus to the element that opened the modal
|
||||||
|
* (best-effort: the trigger may have been removed from the DOM, e.g.
|
||||||
|
* "New chat" clearing the list while the modal is open).
|
||||||
|
* • Close affordances (modal UX standard): #doc-modal-close button,
|
||||||
|
* backdrop click, and the Escape key (captured on document, so it
|
||||||
|
* works no matter where focus is). While open, Tab / Shift+Tab are
|
||||||
|
* trapped inside the panel.
|
||||||
|
* • #doc-modal-open ("Full page") is the escape hatch to the
|
||||||
|
* dedicated viewer: its href is set on open to the same
|
||||||
|
* /document.html?source=…&path=… URL the chip/table link carries,
|
||||||
|
* so the no-JS / direct-link page is always one click away.
|
||||||
|
*
|
||||||
|
* Import safety: the top level only binds controls that exist on the
|
||||||
|
* page (a missing element is a no-op — the header.js pattern). document
|
||||||
|
* .js is imported for renderDocument alone; its /document.html-specific
|
||||||
|
* init is guarded there, so importing this module on the chat/sources
|
||||||
|
* pages has no side effects beyond binding the modal itself.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { renderDocument } from "./document.js";
|
||||||
|
|
||||||
|
const modalEl = document.querySelector("#doc-modal");
|
||||||
|
const backdropEl = document.querySelector("#doc-modal-backdrop");
|
||||||
|
const panelEl = document.querySelector("#doc-modal-panel");
|
||||||
|
const titleEl = document.querySelector("#doc-modal-title");
|
||||||
|
const metaEl = document.querySelector("#doc-modal-meta");
|
||||||
|
const contentEl = document.querySelector("#doc-modal-content");
|
||||||
|
const openEl = document.querySelector("#doc-modal-open");
|
||||||
|
const closeEl = document.querySelector("#doc-modal-close");
|
||||||
|
const descEl = document.querySelector("#doc-modal-desc");
|
||||||
|
|
||||||
|
/* The element that opened the modal — closeDocumentModal restores focus
|
||||||
|
* to it (best-effort, see the header note). */
|
||||||
|
let triggerEl = null;
|
||||||
|
/* Monotonic fetch sequence: a close or a re-open for another document
|
||||||
|
* must not render a stale response into the modal (rapid
|
||||||
|
* open → close → open). */
|
||||||
|
let fetchSeq = 0;
|
||||||
|
|
||||||
|
/* Same encoding the chips and the Sources table links use — both query
|
||||||
|
* values percent-encoded (real paths contain slashes, sometimes spaces). */
|
||||||
|
function contentUrl(source, path) {
|
||||||
|
return "/api/documents/content?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fullPageUrl(source, path) {
|
||||||
|
return "/document.html?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLine(text) {
|
||||||
|
const p = document.createElement("p");
|
||||||
|
p.className = "doc-modal-loading";
|
||||||
|
p.setAttribute("role", "status");
|
||||||
|
p.textContent = text;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDesc(text) {
|
||||||
|
if (descEl) descEl.textContent = text; // the #doc-modal-desc role=status announcer
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoading() {
|
||||||
|
if (titleEl) titleEl.textContent = "Loading…";
|
||||||
|
if (metaEl) metaEl.replaceChildren();
|
||||||
|
if (contentEl) contentEl.replaceChildren(statusLine("Loading document…"));
|
||||||
|
setDesc("Document content is loading.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNotFound(path) {
|
||||||
|
if (titleEl) titleEl.textContent = "Document not found";
|
||||||
|
if (metaEl) metaEl.replaceChildren();
|
||||||
|
if (contentEl) {
|
||||||
|
contentEl.replaceChildren(
|
||||||
|
statusLine(`“${path}” was not found — it may have been removed in a re-import.`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setDesc("Document not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus trap while the modal is open: Tab / Shift+Tab cycle within the
|
||||||
|
* panel (the visible controls are "Full page" + Close; the content
|
||||||
|
* target is programmatic-focus only, tabindex="-1"). */
|
||||||
|
const FOCUSABLE =
|
||||||
|
'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
|
function onModalKeydown(e) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
closeDocumentModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key !== "Tab" || !panelEl) return;
|
||||||
|
const focusable = Array.from(panelEl.querySelectorAll(FOCUSABLE)).filter(
|
||||||
|
(el) => el.getClientRects().length > 0,
|
||||||
|
);
|
||||||
|
if (!focusable.length) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (contentEl) contentEl.focus({ preventScroll: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
const active = document.activeElement;
|
||||||
|
const inside = active !== null && panelEl.contains(active);
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (!inside || active === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
}
|
||||||
|
} else if (!inside || active === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show the modal for (source, path) and fetch + render the document.
|
||||||
|
* `trigger` (the chip / table link that was clicked) is remembered so
|
||||||
|
* closeDocumentModal can return focus to it. */
|
||||||
|
export function openDocumentModal(source, path, trigger = null) {
|
||||||
|
if (!modalEl || !titleEl || !metaEl || !contentEl) {
|
||||||
|
// No modal skeleton on this page (should not happen for the pages
|
||||||
|
// that import this module) — fall back to the dedicated viewer
|
||||||
|
// instead of swallowing the click.
|
||||||
|
window.open(fullPageUrl(source, path), "_blank", "noopener");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
triggerEl = trigger && typeof trigger.focus === "function" ? trigger : null;
|
||||||
|
modalEl.hidden = false; // .doc-modal[hidden] { display:none } — visible now
|
||||||
|
showLoading();
|
||||||
|
if (openEl) {
|
||||||
|
openEl.href = fullPageUrl(source, path); // the "Full page" escape hatch
|
||||||
|
openEl.hidden = false;
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", onModalKeydown, true); // Escape + Tab trap
|
||||||
|
contentEl.focus({ preventScroll: true }); // a11y: focus moves INTO the dialog
|
||||||
|
|
||||||
|
const seq = ++fetchSeq;
|
||||||
|
fetch(contentUrl(source, path))
|
||||||
|
.then(async (r) => {
|
||||||
|
if (seq !== fetchSeq) return; // stale — closed or re-opened meanwhile
|
||||||
|
if (!r.ok) {
|
||||||
|
renderNotFound(path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const doc = await r.json();
|
||||||
|
if (seq !== fetchSeq) return;
|
||||||
|
renderDocument(doc, { titleEl, metaEl, contentEl });
|
||||||
|
setDesc("Document loaded.");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (seq === fetchSeq) renderNotFound(path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide the modal, drop the Escape/Tab capture, and restore focus to the
|
||||||
|
* triggering control (best-effort — it may have been removed from the
|
||||||
|
* DOM since the modal opened). */
|
||||||
|
export function closeDocumentModal() {
|
||||||
|
if (!modalEl || modalEl.hidden) return;
|
||||||
|
fetchSeq += 1; // any in-flight fetch is stale now
|
||||||
|
modalEl.hidden = true;
|
||||||
|
document.removeEventListener("keydown", onModalKeydown, true);
|
||||||
|
if (openEl) {
|
||||||
|
openEl.hidden = true;
|
||||||
|
openEl.removeAttribute("href");
|
||||||
|
}
|
||||||
|
if (triggerEl && typeof triggerEl.focus === "function" && document.contains(triggerEl)) {
|
||||||
|
triggerEl.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
triggerEl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top-level bindings — only on pages that carry the modal skeleton
|
||||||
|
* (a missing element is a no-op, the header.js pattern). The Escape /
|
||||||
|
* Tab capture is per-open (added/removed in open/close), so it never
|
||||||
|
* leaks into the page behind a closed modal. */
|
||||||
|
if (closeEl) closeEl.addEventListener("click", closeDocumentModal);
|
||||||
|
if (backdropEl) backdropEl.addEventListener("click", closeDocumentModal);
|
||||||
+98
-60
@@ -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
|
* Two jobs:
|
||||||
* endpoint (GET /api/documents/content — database only, no filesystem),
|
|
||||||
* and renders:
|
|
||||||
*
|
*
|
||||||
* • md / markdown → the shared escape-first renderer (markdown.js) in a
|
* 1. renderDocument(doc, { titleEl, metaEl, contentEl }) — the
|
||||||
* ≤46rem centered column;
|
* 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
|
* • 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
|
* XSS-safe by construction: markdown is escaped before transform, raw
|
||||||
* formats are set via textContent, and every document-derived string
|
* formats are set via textContent, and every document-derived string
|
||||||
* (title, badges, path) is written with textContent — never innerHTML.
|
* (title, badges, path) is written with textContent — never innerHTML
|
||||||
*
|
* (innerHTML goes through renderMarkdown, which escapes first).
|
||||||
* A missing document (unknown pair, missing params, network error) shows
|
|
||||||
* the designed not-found card with a link back to the Sources page.
|
|
||||||
*
|
*
|
||||||
* Phase 19: the viewer joins the shared header (assets/header.js) — the
|
* Phase 19: the viewer joins the shared header (assets/header.js) — the
|
||||||
* whoami fetch is the module's cached promise (one request per page,
|
* whoami fetch is the module's cached promise (one request per page,
|
||||||
* shared with initSharedHeader's toggling), and the bar gains the New
|
* 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,
|
* chat button: on a non-chat page "new chat" means going to the chat,
|
||||||
* fresh (clear the phase-14 conversation key, then navigate to "/").
|
* 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";
|
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) {
|
function fmtDate(iso) {
|
||||||
try {
|
try {
|
||||||
return new Date(iso).toLocaleString();
|
return new Date(iso).toLocaleString();
|
||||||
@@ -81,9 +62,15 @@ function metaBadge(cls, text) {
|
|||||||
return el;
|
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;
|
titleEl.textContent = doc.title;
|
||||||
document.title = `${doc.title} · Brain of Reese`;
|
|
||||||
|
|
||||||
const pathCode = document.createElement("code");
|
const pathCode = document.createElement("code");
|
||||||
pathCode.className = "doc-path";
|
pathCode.className = "doc-path";
|
||||||
@@ -96,7 +83,6 @@ function render(doc) {
|
|||||||
metaBadge("doc-chunks", `${doc.chunks} chunk${doc.chunks === 1 ? "" : "s"}`),
|
metaBadge("doc-chunks", `${doc.chunks} chunk${doc.chunks === 1 ? "" : "s"}`),
|
||||||
);
|
);
|
||||||
|
|
||||||
notFoundEl.hidden = true;
|
|
||||||
contentEl.replaceChildren();
|
contentEl.replaceChildren();
|
||||||
if (doc.format === "md" || doc.format === "markdown") {
|
if (doc.format === "md" || doc.format === "markdown") {
|
||||||
const wrap = document.createElement("div");
|
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() {
|
function showNotFound() {
|
||||||
titleEl.textContent = "Document not found";
|
titleEl.textContent = "Document not found";
|
||||||
document.title = "Document not found · Brain of Reese";
|
document.title = "Document not found · Brain of Reese";
|
||||||
@@ -119,19 +155,20 @@ function showNotFound() {
|
|||||||
notFoundEl.hidden = false;
|
notFoundEl.hidden = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Phase 19: the shared header controls (Sign in / Sign out — exactly one
|
/* Phase 19: the shared header controls (Sign in / Sign out — exactly
|
||||||
* visible) are toggled here; the viewer has no nav, so there is no
|
* one visible) are toggled here; the viewer has no nav, so there is
|
||||||
* #nav-sources for the module to touch. Independent of the doc fetch
|
* no #nav-sources for the module to touch. Independent of the doc
|
||||||
* (its own IIFE — load() below never waits on it).
|
* fetch (its own IIFE — load() below never waits on it).
|
||||||
* (fetchIsAdmin is imported for parity with the other header consumers —
|
* (fetchIsAdmin is imported for parity with the other header
|
||||||
* the module's cached promise is the single whoami per page either way.) */
|
* consumers — the module's cached promise is the single whoami per
|
||||||
|
* page either way.) */
|
||||||
(async () => {
|
(async () => {
|
||||||
await initSharedHeader();
|
await initSharedHeader();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
/* Phase 19: New chat on a non-chat page means "go to the chat, fresh":
|
/* Phase 19: New chat on a non-chat page means "go to the chat,
|
||||||
* clear the phase-14 conversation key, then land on the chat page — its
|
* fresh": clear the phase-14 conversation key, then land on the chat
|
||||||
* empty state, since the conversation is gone from storage. */
|
* page — its empty state, since the conversation is gone from storage. */
|
||||||
const newChatBtn = document.querySelector("#new-chat-btn");
|
const newChatBtn = document.querySelector("#new-chat-btn");
|
||||||
if (newChatBtn) {
|
if (newChatBtn) {
|
||||||
newChatBtn.addEventListener("click", () => {
|
newChatBtn.addEventListener("click", () => {
|
||||||
@@ -160,3 +197,4 @@ async function load() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
load();
|
load();
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,9 +10,19 @@
|
|||||||
* page, shared with the header toggling), and the header gains the New
|
* 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,
|
* chat button: on a non-chat page "new chat" means going to the chat,
|
||||||
* fresh (clear the phase-14 conversation key, then navigate to "/").
|
* 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 { 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 tbody = document.querySelector("#docs-tbody");
|
||||||
const emptyEl = document.querySelector("#sources-empty");
|
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
|
/* Viewer link (phase 10) — same encoded URL the chat chips use; both
|
||||||
* values are percent-encoded (paths contain slashes, sometimes spaces). */
|
* 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) {
|
export function documentUrl(source, path) {
|
||||||
return "/document.html?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(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
|
sourceTd.textContent = d.source; // document-derived text — never innerHTML
|
||||||
tr.appendChild(sourceTd);
|
tr.appendChild(sourceTd);
|
||||||
|
|
||||||
// Path cell: a link to the document viewer (phase 10), full path as the
|
// Path cell: a link to the document (phase 10), full path as the
|
||||||
// accessible/hover name (the column is ellipsized).
|
// 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");
|
const pathTd = document.createElement("td");
|
||||||
pathTd.title = d.path; // full path on hover (column is ellipsized)
|
pathTd.title = d.path; // full path on hover (column is ellipsized)
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.className = "doc-link";
|
link.className = "doc-link";
|
||||||
link.href = documentUrl(d.source, d.path);
|
link.href = documentUrl(d.source, d.path);
|
||||||
link.target = "_blank"; // open the full document in a new tab
|
link.addEventListener("click", (e) => {
|
||||||
link.rel = "noopener";
|
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.title = d.path; // full path as the link's hover/accessible name
|
||||||
link.textContent = d.path;
|
link.textContent = d.path;
|
||||||
pathTd.appendChild(link);
|
pathTd.appendChild(link);
|
||||||
|
|||||||
@@ -1204,6 +1204,163 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
}
|
}
|
||||||
.doc-link:hover, .doc-link:focus-visible { text-decoration: underline; }
|
.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 ---------- */
|
/* ---------- Footer ---------- */
|
||||||
.app-footer {
|
.app-footer {
|
||||||
border-top: 1px solid var(--line);
|
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-path { max-width: 16rem; }
|
||||||
.doc-md { padding: 1.1rem 1rem; }
|
.doc-md { padding: 1.1rem 1rem; }
|
||||||
.doc-raw { padding: 1rem; font-size: 0.8rem; }
|
.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; }
|
.composer { padding: 0.5rem; }
|
||||||
.footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; }
|
.footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; }
|
||||||
main { padding-bottom: env(safe-area-inset-bottom, 0); }
|
main { padding-bottom: env(safe-area-inset-bottom, 0); }
|
||||||
|
|||||||
@@ -117,5 +117,37 @@
|
|||||||
own `import "./header.js"` — a hoisted import that is evaluated
|
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. -->
|
||||||
<script type="module" src="/assets/app.js"></script>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+38
-1
@@ -121,7 +121,44 @@
|
|||||||
|
|
||||||
<!-- Phase 19: the shared header module loads through the page script's
|
<!-- Phase 19: the shared header module loads through the page script's
|
||||||
own `import "./header.js"` — a hoisted import that is evaluated
|
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>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ def test_conversation_survives_reload(
|
|||||||
chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md")
|
chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md")
|
||||||
expect(chip).to_have_count(1)
|
expect(chip).to_have_count(1)
|
||||||
expect(chip.first).to_have_attribute("href", CHIP_HREF)
|
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.
|
# The restore is read-only: storage still holds the same two messages.
|
||||||
assert [m["who"] for m in _stored_parsed(page)["messages"]] == ["user", "brain"]
|
assert [m["who"] for m in _stored_parsed(page)["messages"]] == ["user", "brain"]
|
||||||
|
|||||||
@@ -98,16 +98,16 @@ def test_on_topic_question_streams_grounded_answer(
|
|||||||
|
|
||||||
# Grounded: a kubernetes.md source chip renders under the bubble
|
# 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).
|
# (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 26: the chip opens the document in the SAME-PAGE modal — no new
|
||||||
# phase 13 appends back=/ so the viewer's back button returns to chat.
|
# 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")
|
chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md")
|
||||||
expect(chip).to_have_count(1)
|
expect(chip).to_have_count(1)
|
||||||
expect(chip.first).to_contain_text("kubernetes.md")
|
expect(chip.first).to_contain_text("kubernetes.md")
|
||||||
expect(chip.first).to_have_attribute(
|
expect(chip.first).to_have_attribute(
|
||||||
"href", "/document.html?source=docs&path=homelab%2Fkubernetes.md&back=%2F"
|
"href", "/document.html?source=docs&path=homelab%2Fkubernetes.md&back=%2F"
|
||||||
)
|
)
|
||||||
expect(chip.first).to_have_attribute("target", "_blank")
|
expect(chip.first).not_to_have_attribute("target") # phase 26: modal, not a new tab
|
||||||
expect(chip.first).to_have_attribute("rel", "noopener")
|
|
||||||
|
|
||||||
# Button recovers: enabled + "Send" (never stale).
|
# Button recovers: enabled + "Send" (never stale).
|
||||||
expect(page.locator("#send-btn")).to_be_enabled()
|
expect(page.locator("#send-btn")).to_be_enabled()
|
||||||
|
|||||||
@@ -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
|
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
|
The viewer can be reached directly (no browser history to go back to),
|
||||||
in a NEW tab, where there is no browser history — so the return target is
|
so the return target is carried in the viewer URL: chat chips append
|
||||||
carried in the viewer URL: chat chips append ``&back=%2F`` (resolves to
|
``&back=%2F`` (resolves to "Chat"), Sources links omit the param (the
|
||||||
"Chat"), Sources links omit the param (the viewer's default
|
viewer's default ``/sources.html`` applies → "Sources"). The viewer only
|
||||||
``/sources.html`` applies → "Sources"). The viewer only honors
|
honors same-origin relative ``back`` values; everything else falls back
|
||||||
same-origin relative ``back`` values; everything else falls back to
|
to ``/sources.html``.
|
||||||
``/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):
|
Test → story mapping (Playwright Mapping Rule):
|
||||||
1. ``test_back_from_chat_returns_to_chat`` — question → source chip →
|
1. ``test_back_from_chat_returns_to_chat`` — question → source chip href
|
||||||
new tab with ``&back=%2F`` → back link href ``/`` labeled "Chat" →
|
(carries ``&back=%2F``) → viewer back link href ``/`` labeled "Chat"
|
||||||
click → the chat page.
|
→ click → the chat page.
|
||||||
2. ``test_back_from_sources_returns_to_sources`` — Sources table link →
|
2. ``test_back_from_sources_returns_to_sources`` — Sources table link
|
||||||
new tab without a ``back`` param → back link href ``/sources.html``
|
href (no ``back`` param) → back link href ``/sources.html`` labeled
|
||||||
labeled "Sources" → click → the Sources page.
|
"Sources" → click → the Sources page.
|
||||||
3. ``test_malicious_back_param_is_rejected`` — absolute,
|
3. ``test_malicious_back_param_is_rejected`` — absolute,
|
||||||
protocol-relative, and ``javascript:`` ``back`` values all fall back
|
protocol-relative, and ``javascript:`` ``back`` values all fall back
|
||||||
to ``/sources.html`` (labeled "Sources", navigable).
|
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")
|
chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md")
|
||||||
expect(chip).to_have_count(1, timeout=30_000)
|
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(
|
expect(chip.first).to_have_attribute(
|
||||||
"href", f"/document.html?source={DOC_SOURCE}&path={DOC_PATH}&back=%2F"
|
"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:
|
# The exact href asserted above (the no-JS / new-tab escape hatch).
|
||||||
chip.first.click()
|
page.goto(f"{app_url}/document.html?source={DOC_SOURCE}&path={DOC_PATH}&back=%2F")
|
||||||
viewer = popup_info.value
|
expect(page).to_have_url(
|
||||||
expect(viewer).to_have_url(
|
|
||||||
re.compile(
|
re.compile(
|
||||||
re.escape(f"{app_url}/document.html?source={DOC_SOURCE}&path={DOC_PATH}&back=%2F")
|
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).
|
# 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 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_attribute("href", "/")
|
||||||
expect(back).to_have_text("Chat")
|
expect(back).to_have_text("Chat")
|
||||||
|
|
||||||
# Click: deterministic anchor navigation back to the chat page.
|
# Click: deterministic anchor navigation back to the chat page.
|
||||||
back.click()
|
back.click()
|
||||||
expect(viewer).to_have_url(f"{app_url}/")
|
expect(page).to_have_url(f"{app_url}/")
|
||||||
expect(viewer.locator("#composer")).to_be_visible()
|
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")
|
link = row.locator("td:nth-child(2) a.doc-link")
|
||||||
expect(link).to_have_count(1)
|
expect(link).to_have_count(1)
|
||||||
# Sources links carry NO back param — the viewer's default target
|
# 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(
|
expect(link).to_have_attribute(
|
||||||
"href", f"/document.html?source={DOC_SOURCE}&path={DOC_PATH}"
|
"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:
|
# The exact href asserted above — no back param in the URL.
|
||||||
link.click()
|
page.goto(f"{app_url}/document.html?source={DOC_SOURCE}&path={DOC_PATH}")
|
||||||
viewer = popup_info.value
|
assert "back=" not in page.url, f"unexpected back param: {page.url}"
|
||||||
assert "back=" not in viewer.url, f"unexpected back param: {viewer.url}"
|
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE)
|
||||||
expect(viewer.locator("#doc-title")).to_have_text(DOC_TITLE)
|
|
||||||
# Back link kept the default target, labeled "Sources".
|
# 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_attribute("href", "/sources.html")
|
||||||
expect(back).to_have_text("Sources")
|
expect(back).to_have_text("Sources")
|
||||||
|
|
||||||
back.click()
|
back.click()
|
||||||
expect(viewer).to_have_url(f"{app_url}/sources.html")
|
expect(page).to_have_url(f"{app_url}/sources.html")
|
||||||
expect(viewer.locator("#docs-table")).to_be_visible()
|
expect(page.locator("#docs-table")).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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``):
|
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||||
|
|
||||||
uv run pytest tests/e2e/test_document_viewer.py -v --no-cov
|
uv run pytest tests/e2e/test_document_viewer.py -v --no-cov
|
||||||
|
|
||||||
Seeding reuses the real importer against ``tests/fixtures/docs/`` with the
|
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):
|
Test → story mapping (Playwright Mapping Rule):
|
||||||
1. ``test_source_chip_opens_document`` — chip → NEW TAB → viewer with
|
1. ``test_source_chip_opens_modal`` — chat chip → modal opens in-page
|
||||||
title + known content string + format badge.
|
(NO new tab, URL unchanged), title + ``.doc-md`` content + meta row.
|
||||||
2. ``test_sources_row_links_to_viewer`` — Sources path link (yaml
|
2. ``test_sources_row_opens_modal`` — Sources path link → modal, yaml in
|
||||||
fixture) → viewer with raw content in a ``pre``.
|
``<pre.doc-raw>``, mono font, URL unchanged.
|
||||||
3. ``test_markdown_renders_and_stays_xss_safe`` — md fixture containing
|
3. ``test_modal_closes_on_button_escape_and_backdrop`` — close via
|
||||||
``<script>alert(1)</script>`` renders as visible escaped text (no
|
``#doc-modal-close``, via backdrop click, via ``Escape``.
|
||||||
execution).
|
4. ``test_modal_focus_and_a11y`` — ``role="dialog"`` + ``aria-modal``,
|
||||||
4. ``test_missing_doc_shows_not_found`` — unknown doc → not-found
|
focus inside the panel on open, close button has an ``aria-label``.
|
||||||
state + Sources link; no console crash.
|
5. ``test_modal_xss_safe`` — hostile md document opened through the modal
|
||||||
5. ``test_viewer_theme_and_no_cdn`` — dark theme + every
|
renders as escaped text; no dialog fires.
|
||||||
``script[src]`` / ``link[href]`` local or ``data:`` + a11y frame.
|
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
|
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))
|
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
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||||
) -> None:
|
) -> None:
|
||||||
_reset_db(mock_llm, seed=True)
|
_reset_db(mock_llm, seed=True)
|
||||||
page.set_default_timeout(30_000)
|
page.set_default_timeout(30_000)
|
||||||
page.goto(app_url)
|
chip = _ask_for_chip(page, app_url)
|
||||||
|
# The encoded viewer URL stays as the no-JS / context-menu escape
|
||||||
page.fill("#message-input", QUESTION)
|
# hatch — but phase 26 removed target=_blank: the left click is
|
||||||
page.click("#send-btn")
|
# intercepted and opens the modal in place.
|
||||||
|
|
||||||
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.
|
|
||||||
expect(chip.first).to_have_attribute(
|
expect(chip.first).to_have_attribute(
|
||||||
"href", "/document.html?source=docs&path=homelab%2Fkubernetes.md&back=%2F"
|
"href", "/document.html?source=docs&path=homelab%2Fkubernetes.md&back=%2F"
|
||||||
)
|
)
|
||||||
expect(chip.first).to_have_attribute("target", "_blank")
|
expect(chip.first).not_to_have_attribute("target")
|
||||||
expect(chip.first).to_have_attribute("rel", "noopener")
|
|
||||||
|
|
||||||
with page.expect_popup() as popup_info:
|
before = len(page.context.pages)
|
||||||
chip.first.click()
|
chip.first.click()
|
||||||
viewer = popup_info.value
|
# No new tab: the click must not have spawned a page.
|
||||||
expect(viewer).to_have_url(
|
assert len(page.context.pages) == before, "clicking a chip must not open a new tab"
|
||||||
re.compile(
|
|
||||||
re.escape(
|
# The almost-fullscreen modal becomes visible (hidden attribute gone).
|
||||||
f"{app_url}/document.html?source=docs&path=homelab%2Fkubernetes.md&back=%2F"
|
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()
|
||||||
)
|
)
|
||||||
)
|
|
||||||
)
|
# Still the chat page: no navigation happened.
|
||||||
expect(viewer.locator("#doc-title")).to_have_text("Kubernetes Homelab Cluster")
|
assert page.url == app_url + "/", f"navigated away: {page.url}"
|
||||||
# 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")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 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
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||||
) -> None:
|
) -> None:
|
||||||
_reset_db(mock_llm, seed=True)
|
_reset_db(mock_llm, seed=True)
|
||||||
@@ -146,34 +177,119 @@ def test_sources_row_links_to_viewer(
|
|||||||
expect(row).to_have_count(1)
|
expect(row).to_have_count(1)
|
||||||
link = row.locator("td:nth-child(2) a.doc-link")
|
link = row.locator("td:nth-child(2) a.doc-link")
|
||||||
expect(link).to_have_count(1)
|
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(
|
expect(link).to_have_attribute(
|
||||||
"href",
|
"href",
|
||||||
"/document.html?source=docs&path=homelab%2Fcontainer_gitlab%2Fgitlab-compose.yaml",
|
"/document.html?source=docs&path=homelab%2Fcontainer_gitlab%2Fgitlab-compose.yaml",
|
||||||
)
|
)
|
||||||
expect(link).to_have_attribute("target", "_blank")
|
expect(link).not_to_have_attribute("target")
|
||||||
expect(link).to_have_attribute("rel", "noopener")
|
|
||||||
expect(link).to_have_attribute("title", "homelab/container_gitlab/gitlab-compose.yaml")
|
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()
|
link.click()
|
||||||
viewer = popup_info.value
|
assert len(page.context.pages) == before, "clicking a row link must not open a new tab"
|
||||||
expect(viewer.locator("#doc-title")).to_have_text("gitlab-compose")
|
|
||||||
expect(viewer.locator("#doc-meta .format-badge")).to_have_text("yaml")
|
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.
|
# 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_have_count(1)
|
||||||
expect(pre).to_contain_text("gitlab/gitlab-ce:17.2.1-ce.0")
|
expect(pre).to_contain_text("gitlab/gitlab-ce:17.2.1-ce.0")
|
||||||
font = pre.evaluate("el => getComputedStyle(el).fontFamily")
|
font = pre.evaluate("el => getComputedStyle(el).fontFamily")
|
||||||
assert "mono" in font
|
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
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||||
) -> None:
|
) -> None:
|
||||||
_reset_db(mock_llm, seed=True)
|
_reset_db(mock_llm, seed=True)
|
||||||
@@ -200,28 +316,46 @@ def test_markdown_renders_and_stays_xss_safe(
|
|||||||
d.dismiss()
|
d.dismiss()
|
||||||
|
|
||||||
page.on("dialog", _catch_dialog)
|
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.
|
# 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-modal-content")).to_contain_text("<script>alert(1)</script>")
|
||||||
expect(page.locator("#doc-content")).to_contain_text("XSS-FIXTURE-MARKER")
|
expect(page.locator("#doc-modal-content")).to_contain_text("XSS-FIXTURE-MARKER")
|
||||||
assert page.locator("#doc-content script").count() == 0, "hostile script became live HTML"
|
assert page.locator("#doc-modal-content script").count() == 0, "hostile script became live HTML"
|
||||||
assert dialogs == [], f"dialog fired — script executed: {dialogs}"
|
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
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||||
) -> None:
|
) -> None:
|
||||||
_reset_db(mock_llm, seed=True)
|
_reset_db(mock_llm, seed=True)
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
page.on("pageerror", lambda e: errors.append(str(e)))
|
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")
|
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")
|
expect(page.locator("#doc-title")).to_have_text("Document not found")
|
||||||
card = page.locator("#doc-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).to_contain_text("Document not found")
|
||||||
expect(card.locator("a.doc-open-sources")).to_have_attribute("href", "/sources.html")
|
expect(card.locator("a.doc-open-sources")).to_have_attribute("href", "/sources.html")
|
||||||
expect(page.locator("#doc-content")).to_be_empty()
|
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")
|
page.goto(f"{app_url}/document.html")
|
||||||
expect(page.locator("#doc-not-found")).to_be_visible()
|
expect(page.locator("#doc-not-found")).to_be_visible()
|
||||||
|
|
||||||
assert errors == [], f"console crashes: {errors}"
|
# Dark theme + all assets local + a11y frame + capped md column.
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 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)
|
|
||||||
page.goto(f"{app_url}/document.html?source=docs&path=homelab%2Fkubernetes.md")
|
page.goto(f"{app_url}/document.html?source=docs&path=homelab%2Fkubernetes.md")
|
||||||
expect(page.locator("#doc-content .doc-md")).not_to_be_empty()
|
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")
|
bg = page.evaluate("() => getComputedStyle(document.documentElement).backgroundColor")
|
||||||
assert bg == "rgb(10, 14, 23)"
|
assert bg == "rgb(10, 14, 23)"
|
||||||
|
|
||||||
# No-CDN: every script/link reference is same-origin or a data: URI.
|
|
||||||
refs = page.evaluate(
|
refs = page.evaluate(
|
||||||
"""() => [...document.querySelectorAll("script[src], link[href]")]
|
"""() => [...document.querySelectorAll("script[src], link[href]")]
|
||||||
.map((el) => el.src || el.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:
|
for ref in refs:
|
||||||
assert ref.startswith(app_url) or ref.startswith("data:"), f"non-local: {ref}"
|
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("header.doc-header")).to_have_count(1)
|
||||||
expect(page.locator("main#main")).to_have_count(1)
|
expect(page.locator("main#main")).to_have_count(1)
|
||||||
expect(page.locator("footer.app-footer")).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).
|
# Markdown column centered and capped at 46rem (736px at 16px root).
|
||||||
box = page.locator("#doc-content .doc-md").bounding_box()
|
box = page.locator("#doc-content .doc-md").bounding_box()
|
||||||
assert box is not None and box["width"] <= 736 + 1
|
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}"
|
||||||
|
|||||||
@@ -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/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/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/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
|
# 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/markdown.js",
|
||||||
"/assets/document.js",
|
"/assets/document.js",
|
||||||
"/assets/login.js", # phase 16
|
"/assets/login.js", # phase 16
|
||||||
|
"/assets/document-modal.js", # phase 26: the document modal module
|
||||||
"/assets/styles.css",
|
"/assets/styles.css",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ Frontend side:
|
|||||||
spaces/slashes) executed under node when available, plus source pins that
|
spaces/slashes) executed under node when available, plus source pins that
|
||||||
run everywhere;
|
run everywhere;
|
||||||
* the shared-renderer extraction — ``markdown.js`` holds the renderer,
|
* the shared-renderer extraction — ``markdown.js`` holds the renderer,
|
||||||
loaded by BOTH pages via a relative ``<script src>`` before the module
|
loaded by the pages that use it via a relative ``<script src>`` before
|
||||||
scripts.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -37,6 +40,9 @@ APP_JS = FRONTEND / "assets" / "app.js"
|
|||||||
SOURCES_JS = FRONTEND / "assets" / "sources.js"
|
SOURCES_JS = FRONTEND / "assets" / "sources.js"
|
||||||
DOCUMENT_JS = FRONTEND / "assets" / "document.js"
|
DOCUMENT_JS = FRONTEND / "assets" / "document.js"
|
||||||
MARKDOWN_JS = FRONTEND / "assets" / "markdown.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
|
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:
|
def test_viewer_url_builder_present_in_chat_and_sources() -> None:
|
||||||
"""Both entry points (chat chips, Sources rows) build the same
|
"""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
|
Phase 13: the chat builder additionally carries ``back=/`` (encoded
|
||||||
%2F) so the viewer's back button returns to the chat; Sources links
|
%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))):
|
for name, js in (("app.js", _read(APP_JS)), ("sources.js", _read(SOURCES_JS))):
|
||||||
assert '"/document.html?source=" + encodeURIComponent(' in js, name
|
assert '"/document.html?source=" + encodeURIComponent(' in js, name
|
||||||
assert '"&path=" + 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)
|
app_js = _read(APP_JS)
|
||||||
# Chat: 3-arg builder with back defaulting to the chat page.
|
# Chat: 3-arg builder with back defaulting to the chat page.
|
||||||
assert 'function documentUrl(source, path, back = "/")' in app_js
|
assert 'function documentUrl(source, path, back = "/")' in app_js
|
||||||
assert '"&back=" + encodeURIComponent(back)' in app_js
|
assert '"&back=" + encodeURIComponent(back)' in app_js
|
||||||
assert 'chip.href = documentUrl(s.source, s.path, "/")' in app_js
|
assert 'chip.href = documentUrl(s.source, s.path, "/")' in app_js
|
||||||
assert 'chip.target = "_blank"' in app_js
|
assert 'chip.addEventListener("click"' in app_js
|
||||||
assert 'chip.rel = "noopener"' 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_js = _read(SOURCES_JS)
|
||||||
# Sources: unchanged 2-arg builder — no back param in the URL.
|
# Sources: unchanged 2-arg builder — no back param in the URL.
|
||||||
assert "function documentUrl(source, path)" in sources_js
|
assert "function documentUrl(source, path)" in sources_js
|
||||||
assert 'link.className = "doc-link"' in sources_js
|
assert 'link.className = "doc-link"' in sources_js
|
||||||
assert "link.href = documentUrl(d.source, d.path)" in sources_js
|
assert "link.href = documentUrl(d.source, d.path)" in sources_js
|
||||||
assert 'link.target = "_blank"' in sources_js
|
assert 'link.addEventListener("click"' in sources_js
|
||||||
assert 'link.rel = "noopener"' 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.
|
# The full path stays the hover name on the ellipsized cell AND the link.
|
||||||
assert "pathTd.title = d.path" in sources_js
|
assert "pathTd.title = d.path" in sources_js
|
||||||
assert "link.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
|
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:
|
def test_viewer_js_rendering_contracts() -> None:
|
||||||
"""document.js: raw formats go in via textContent (never parsed as
|
"""document.js: raw formats go in via textContent (never parsed as
|
||||||
HTML), markdown via the shared renderer, 404 → designed not-found
|
HTML), markdown via the shared renderer, 404 → designed not-found
|
||||||
|
|||||||
Reference in New Issue
Block a user