phase: 144_modal_back_button
Build and Push Containers / build-and-push-app (push) Successful in 2m21s
Build and Push Containers / build-and-push-db (push) Successful in 17s

**Phase 144 final verification pass — complete, 100% green.**

- **Verified:** all 3 tasks already implemented in-tree — `document-modal.js` history-aware modal (one state-only entry per open: push on open, adopt on forward re-open, in-place `replaceState(prev)` on button/Escape/backdrop close, `closingViaBack`-guarded back-close, top-level popstate listener), `tests/unit/test_frontend_doc_modal.py` source pins, `tests/e2e/test_modal_back_button.py` (4 tests). No code fixes needed.
- **Tests:** `uv run pytest` → 3371 passed · `uv run pytest --cov=app --cov-report=term-missing` → 3371 passed, **app/ 99%** (gate >90%) · `uv run pytest tests/e2e/test_modal_back_button.py -v --no-cov` → 4 passed in isolation · regressions in isolation: `test_document_back_navigation.py` 3 passed, `test_kb_tree_nav.py` 4 passed · `uv run ruff check .` clean, `uv run pyright` 0 errors.
- **Completion criteria:** back closes modal (URL/view/drill untouched; second back navigates as usual) ✅ · back→forward re-opens same doc, no duplicate entry ✅ · button/Escape/backdrop close leaves no dead back step ✅ (state byte-identity + modal-free back chain; `replaceState` can't shrink `history.length` — documented deviation, locked design forbids `history.back()/go()`) · all gates green ✅ · no behavior change in completed phases (pre-phase pins + both regression E2Es green) ✅.
- **Docs:** README — one feature-description gap fixed (added the phase-144 Back/Forward modal behavior to the Chat section); all other README facts verified against code (routes, every `.env.example` var, all ~40 documented defaults vs `app/config.py`). AGENTS.md — verified consistent, no changes needed.
- **Decisions:** no live-infrastructure work touched; all changes left uncommitted in the working tree per harness protocol.
- **Next pending phase:** `145_git_source_links`.
This commit is contained in:
2026-10-01 08:49:50 -04:00
parent ba4841a811
commit 486ceb4019
21 changed files with 1431 additions and 1 deletions
+85
View File
@@ -27,6 +27,15 @@
* 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.
* • History (phase 144, TODO L6): the open owns ONE state-only
* entry ({ modal: "doc", source, path } — the URL does not move, so
* the router's pathname-only popstate is a no-op and the Sources
* drill re-adopts the state it was already showing): Back closes
* the modal (view/URL/drill untouched), a button/Escape/backdrop
* close removes the entry IN PLACE (the next Back behaves as if the
* modal never opened), and Forward onto the entry re-opens the same
* document (the browser owns its entries — the phase-99 D2
* convention).
* • #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,
@@ -58,6 +67,23 @@ let triggerEl = null;
* must not render a stale response into the modal (rapid
* open → close → open). */
let fetchSeq = 0;
/* The modal's history entry (phase 144, TODO L6): { prev, source,
* path } while the modal is open — prev is the below-entry to restore
* on a non-back close; source/path name the doc the entry carries.
* Written ONLY by openDocumentModal (push on a normal open, adopt on
* the forward re-open, in-place replace on a defensive re-open-while-
* open); nulled by closeDocumentModal. */
let entry = null;
/* The below-entry remembered at each back-close (the popstate handler
* is its only writer): a forward re-open adopts it as the entry's
* prev — at that moment the browser sits ON the modal entry, so the
* below-entry is known only from here. Null/stale otherwise. */
let lastPrev = null;
/* True ONLY around the popstate handler's closeDocumentModal call
* (a back press already moved us off the entry, so that close must
* not replaceState it again). Set and reset by the popstate handler
* alone. */
let closingViaBack = false;
/* Same encoding the chips and the Sources table links use — both query
* values percent-encoded (real paths contain slashes, sometimes spaces). */
@@ -146,6 +172,29 @@ export function openDocumentModal(source, path, trigger = null) {
window.open(fullPageUrl(source, path), "_blank", "noopener");
return;
}
/* Phase 144 (TODO L6): the modal owns ONE state-only history entry —
* Back closes the modal instead of navigating the view behind it.
* The entry is state-only (second arg "" — the URL does not move),
* so the router's pathname-only popstate is a no-op and the Sources
* drill re-adopts the state it was already showing (the sources.js
* state-only-entry convention, L1539). The current entry is a modal
* entry only on the forward re-open (the popstate handler below
* calls us sitting on it) — then we ADOPT it; otherwise we push. */
const st = history.state;
if (entry) {
// Defensive re-open while open: replace our entry in place (the
// remembered prev is kept) — never stack a second entry.
entry.source = source;
entry.path = path;
history.replaceState({ modal: "doc", source, path }, "");
} else if (st && st.modal === "doc") {
// Forward onto our own entry: adopt — the browser already sits on
// it (lastPrev is the below-entry remembered at the back-close).
entry = { prev: lastPrev, source, path };
} else {
entry = { prev: st, source, path };
history.pushState({ modal: "doc", source, path }, "");
}
triggerEl = trigger && typeof trigger.focus === "function" ? trigger : null;
modalEl.hidden = false; // .doc-modal[hidden] { display:none } — visible now
showLoading();
@@ -179,6 +228,15 @@ export function openDocumentModal(source, path, trigger = null) {
* DOM since the modal opened). */
export function closeDocumentModal() {
if (!modalEl || modalEl.hidden) return;
/* Phase 144: a NON-back close removes the modal's entry IN PLACE —
* no popstate fires, no other listener runs, the view state is back
* byte-for-byte, so the next Back behaves as if the modal never
* opened. A back close (closingViaBack — the popstate handler) has
* already been moved off the entry by the browser: no replace. */
if (entry && !closingViaBack && history.state && history.state.modal === "doc") {
history.replaceState(entry.prev, "");
}
entry = null;
fetchSeq += 1; // any in-flight fetch is stale now
modalEl.hidden = true;
document.removeEventListener("keydown", onModalKeydown, true);
@@ -198,3 +256,30 @@ export function closeDocumentModal() {
* leaks into the page behind a closed modal. */
if (closeEl) closeEl.addEventListener("click", closeDocumentModal);
if (backdropEl) backdropEl.addEventListener("click", closeDocumentModal);
/* Phase 144 (TODO L6): back/forward onto the modal's entry. popstate
* fires with the state of the entry we LANDED on. Back: the modal is
* open and we landed on a non-modal state — the browser moved off our
* entry; close the modal (remember the landed state as lastPrev for
* the forward re-open; closingViaBack keeps the close from also
* replaceState-ing). Forward: the modal is closed and we landed on a
* modal entry — the browser owns its entries (the phase-99 D2
* convention) — re-open the doc the entry names (the open adopts the
* entry — no second push). Every other popstate (the router's view
* entries, the drill's kb entries) passes through untouched. A missing
* skeleton is a no-op via the entry/modalEl guards (the header.js
* pattern). */
window.addEventListener("popstate", (event) => {
const st = event.state;
const isModalEntry = st && st.modal === "doc";
if (isModalEntry && modalEl && modalEl.hidden) {
openDocumentModal(st.source, st.path, null);
return;
}
if (!isModalEntry && modalEl && !modalEl.hidden && entry) {
lastPrev = st;
closingViaBack = true;
closeDocumentModal();
closingViaBack = false;
}
});