# URL-Addressable Modals Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Pilot the swift3-style URL-addressable modal pattern in left4me by migrating the file editor's open/render flow. Same URL renders as a full page or a layoutless fragment based on an `HX-Modal: 1` request header. Save flow stays AJAX. **Architecture:** Approach C (Hybrid). Custom ~50-line `modal-router.js` owns click intercept, `?modal=` URL composition, history, and native `` open/close. HTMX (already loaded) owns fetch + swap + loading state. Jinja `inject_base_layout` context processor switches between `base.html` and `_modal_partial.html` based on the header. **Tech Stack:** Flask 3.x + Jinja2, HTMX 2.0.4, native ``, CodeMirror 6 (already bundled as `editor.bundle.js`), pytest for backend tests, Chromium for frontend verification. **Spec reference:** `docs/superpowers/specs/2026-05-17-url-addressable-modals-design.md` --- ## Errata (post-execution) The plan shipped via 14 commits between 2026-05-17 and the same day's evening. Three defects in the verbatim plan code were caught by code review during execution; if you re-run this plan, watch for them: 1. **Task 1, Step 4 — context processor needs a `has_request_context()` guard.** Plan code reads `request.headers.get("HX-Modal")` unconditionally, but `tests/test_timeago.py` renders templates inside `app.app_context()` only (no request context). Without the guard the processor crashes with `RuntimeError: Working outside of request context`. Fix: `is_modal = has_request_context() and request.headers.get("HX-Modal") == "1"` (lazy import `from flask import has_request_context` is fine). Shipped in commit `82c3f04`. 2. **Task 3, Step 1 — test fixture must respect `LEFT4ME_ROOT`.** Plan code uses `path=str(overlay_root)` (absolute filesystem path) on the `Overlay` model. The codebase resolves `overlay.path` relative to `LEFT4ME_ROOT` via `validate_overlay_ref` and rejects absolute paths. Fix: `monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))`, write files to `tmp_path/overlays//`, set `overlay.path = str(overlay.id)`. Mirrors `tests/test_overlay_files_routes.py`'s convention. Shipped in commit `60e7968`. 3. **Task 9, Step 2 — "save flow unchanged" was wrong.** The legacy save/delete handlers in `files-overlay.js` are direct-bound to `editorEls.saveBtn` / `editorEls.deleteBtn` (the inline dialog's specific elements), not document-delegated. The new server-rendered modal's identical-class buttons get no handler. Fix: add document-level event delegation for `.files-editor-save` and `.files-editor-delete` clicks gated on `modalContent.contains(btn)`, read `data-rel-path` from the textarea (NOT from a JS var the now-deleted open path used to set), use `window.__filesEditor.getValue()`, POST + `closeModal()` + `scheduleRefresh(parentOf(path))`. Also support rename: read filename input, compose `payload.new_path = parent/filename` when changed, handle 409 with alert + keep modal open. Shipped across commits `64cf203` and `33a2e52`. ## Tasks added during execution Three tasks were inserted that weren't in the original plan: - **Task 8.5 (commit `f6b8ecf`)** — `overlay_file_editor.html`'s `` nested inside `` collapses to 2 px tall in browsers. Replaced with `
`. Bundled with CM6 `controller.destroy()` on modal close (memory leak fix — every open/close cycle had been orphaning an `EditorView` and a `matchMedia` listener) and a `mountOne` idempotency guard. CSS broadened: `dialog.modal, div.modal`. - **Task 8.5b (commit `7829d1c`)** — the broadened CSS caused double-card painting (outer dialog + inner div both matched the `.modal` styling). Dropped `class="modal modal-wide"` and `role="document"` from the inner div; the outer dialog owns the chrome. - **Task 9b (commit `33a2e52`)** — see defect #3 above for rename-on-save support. ## Design refinement during execution (Task 6 superseded) Task 6's original "every close source updates state directly" code was replaced with a close-event-centric design: every close source (Esc cancel, backdrop click, `[data-modal-dismiss]`, browser back, `htmx:responseError`, programmatic close) just calls `dialog.close()`, and a single `close`-event listener clears `currentModalPath` and removes `?modal=` from the URL. This kills two latent bugs simultaneously: (a) the legacy `modal.js:31-33` backdrop handler closes `dialog.modal` without clearing URL, and (b) HTMX's `htmx.ajax` resolves on 4xx so plain `.then(() => showModal())` would open a modal on error responses. Shipped in commit `6e66375`. The revised design is in that commit's diff. ## Post-pilot polish (commits 5dc4xx after Task 10) - Removed dangling `aria-labelledby="modal-content-title"` from `#modal-container` in `base.html` (referenced an id that never existed). - Renamed the new editor template's outer `
` id from `files-editor-modal` to `files-editor-fragment` to resolve a duplicate-id W3C violation with the legacy inline `` in `overlay_detail.html`. Updated `editor.js`'s `closest()` to match both selectors so auto-language detection works for both modal pipelines. --- ## File Structure | Path | New / Modify | Responsibility | |------|--------------|----------------| | `l4d2web/l4d2web/app.py` | Modify (insert ~5 lines after `add_template_filter`) | Register `inject_base_layout` context processor | | `l4d2web/l4d2web/templates/_modal_partial.html` | New (1 line) | Layoutless base template — just `{% block content %}{% endblock %}` | | `l4d2web/l4d2web/templates/overlay_file_editor.html` | New | Editor markup lifted from `overlay_detail.html:165-228`, content pre-filled, extends `base_layout` | | `l4d2web/l4d2web/routes/files_routes.py` | Modify (add one route, ~30 lines) | `GET /overlays//files/edit?path=` | | `l4d2web/l4d2web/templates/base.html` | Modify (insert ~3 lines) | Persistent `` slot + `modal-router.js` script include | | `l4d2web/l4d2web/static/js/modal-router.js` | New (~60 lines) | Click intercept, URL composition, history, open/close, bootstrap | | `l4d2web/l4d2web/static/js/editor.js` | Modify (expose `initEditors(root)`, add `htmx:afterSwap` listener) | CM6 re-init after HTMX swap | | `l4d2web/l4d2web/static/js/files-overlay.js` | Modify (change one code path) | Replace inline-dialog populate-and-show with `window.openModal(url)` | | `l4d2web/l4d2web/templates/overlay_detail.html` | Modify (remove `` block at lines 165-228) | Delete the old inline editor dialog | | `l4d2web/tests/test_url_addressable_modals.py` | New | pytest coverage for context processor + new edit route | --- ## Task 1: Layout context processor + partial template **Files:** - Create: `l4d2web/l4d2web/templates/_modal_partial.html` - Modify: `l4d2web/l4d2web/app.py` (insert after `app.add_template_filter(format_time_html, "timeago")` on line 62) - Test: `l4d2web/tests/test_url_addressable_modals.py` (new) - [ ] **Step 1: Write the failing test** Create `l4d2web/tests/test_url_addressable_modals.py`: ```python from flask import render_template_string from l4d2web.app import create_app def _make_app(tmp_path, monkeypatch, db_name: str): db_url = f"sqlite:///{tmp_path/db_name}" monkeypatch.setenv("DATABASE_URL", db_url) return create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"}) def test_base_layout_is_modal_partial_when_hx_modal_header_set(tmp_path, monkeypatch): app = _make_app(tmp_path, monkeypatch, "layout-modal.db") with app.test_request_context("/", headers={"HX-Modal": "1"}): assert render_template_string("{{ base_layout }}") == "_modal_partial.html" def test_base_layout_is_base_html_for_normal_request(tmp_path, monkeypatch): app = _make_app(tmp_path, monkeypatch, "layout-default.db") with app.test_request_context("/"): assert render_template_string("{{ base_layout }}") == "base.html" def test_base_layout_does_not_react_to_plain_hx_request_header(tmp_path, monkeypatch): # HTMX sets HX-Request on every request including the build-status poll; # only HX-Modal should switch the layout. app = _make_app(tmp_path, monkeypatch, "layout-hxreq.db") with app.test_request_context("/", headers={"HX-Request": "true"}): assert render_template_string("{{ base_layout }}") == "base.html" ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd l4d2web && uv run pytest tests/test_url_addressable_modals.py -v` Expected: 3 failures (all asserting that `base_layout` resolves to something — currently undefined, so render fails with `UndefinedError` or returns empty string). - [ ] **Step 3: Create the partial template** Create `l4d2web/l4d2web/templates/_modal_partial.html` with exactly this content: ```jinja {% block content %}{% endblock %} ``` - [ ] **Step 4: Register the context processor** In `l4d2web/l4d2web/app.py`, insert immediately after line 62 (`app.add_template_filter(format_time_html, "timeago")`): ```python @app.context_processor def inject_base_layout() -> dict[str, str]: is_modal = request.headers.get("HX-Modal") == "1" return {"base_layout": "_modal_partial.html" if is_modal else "base.html"} ``` `request` is already imported at the top of the file. - [ ] **Step 5: Run tests to verify pass** Run: `cd l4d2web && uv run pytest tests/test_url_addressable_modals.py -v` Expected: 3 passed. - [ ] **Step 6: Commit** ```bash git add l4d2web/l4d2web/app.py l4d2web/l4d2web/templates/_modal_partial.html l4d2web/tests/test_url_addressable_modals.py git commit -m "$(cat <<'EOF' feat(modals): layout context processor for HX-Modal header Switches the Jinja base layout to _modal_partial.html (yield-only) when the HX-Modal:1 request header is set, otherwise base.html. Foundation for URL-addressable modals (spec 2026-05-17-url-addressable-modals). Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 2: Editor template (file editor as standalone page) **Files:** - Create: `l4d2web/l4d2web/templates/overlay_file_editor.html` - Test: covered by Task 3's route tests (template is unreachable until then) This task is a lift-and-shift of the editor markup from `overlay_detail.html:165-228` into its own template with server-side content variables substituted in. - [ ] **Step 1: Read the source markup to lift** Run: `sed -n '164,228p' l4d2web/l4d2web/templates/overlay_detail.html` Note the surrounding `{% if files_can_edit %}` guard — that gating moves to the route (only `files` overlays expose the link). The template itself unconditionally renders the editor. - [ ] **Step 2: Create the new template** Create `l4d2web/l4d2web/templates/overlay_file_editor.html`: ```jinja {% extends base_layout %} {% block title %}Edit {{ rel_path }} · {{ overlay.name }}{% endblock %} {% block extra_head %}{% include "_editor_assets.html" %}{% endblock %} {% block content %} {% endblock %} ``` Notes baked into the markup: - `{% extends base_layout %}` — picks `_modal_partial.html` or `base.html` based on the request header - `` for the full-page render — when standalone, the dialog stays open without `showModal()`. When fragment-rendered into the modal slot, `modal-router.js` calls `showModal()` on the *outer* `#modal-container` (not this inner dialog — see Task 4) - `data-modal-dismiss` on close buttons — picked up by modal-router (deferred to Task 6) - `data-overlay-id` + `data-rel-path` on the textarea — so the AJAX save in `files-overlay.js` can find its target without depending on global state - Binary-file replacement UI from `overlay_detail.html:204-219` is **omitted** from this pilot template. Editable-only files reach this route (the route returns 415 for non-editable per Task 3). Binary replace stays inline-modal for now (out of pilot scope) - [ ] **Step 3: Commit** ```bash git add l4d2web/l4d2web/templates/overlay_file_editor.html git commit -m "$(cat <<'EOF' feat(modals): editor template that extends base_layout Lifts the file editor markup out of overlay_detail.html into its own template with server-side filename, content, byte count, and download URL pre-filled. Uses {% extends base_layout %} so the same template renders as either a full page or a layoutless modal fragment. Binary replace UI deferred — pilot scope is editable text files only. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 3: New GET `/overlays//files/edit` route **Files:** - Modify: `l4d2web/l4d2web/routes/files_routes.py` (add one route, ~35 lines) - Test: `l4d2web/tests/test_url_addressable_modals.py` (extend) The route mirrors the existing `overlay_file_content` at `files_routes.py:203-234`: resolves the path, checks editability, reads UTF-8 content. Difference: returns HTML (via `overlay_file_editor.html`) instead of JSON. - [ ] **Step 1: Write the failing tests** Append to `l4d2web/tests/test_url_addressable_modals.py`: ```python from datetime import UTC, datetime from l4d2web.auth import hash_password from l4d2web.db import init_db, session_scope from l4d2web.models import Overlay, User def _auth_client_with_files_overlay(tmp_path, monkeypatch, db_name: str): db_url = f"sqlite:///{tmp_path/db_name}" monkeypatch.setenv("DATABASE_URL", db_url) app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"}) init_db() overlay_root = tmp_path / "overlay_root" overlay_root.mkdir() (overlay_root / "server.cfg").write_text("hostname \"left4me\"\nrcon_password \"hunter2\"\n", encoding="utf-8") with session_scope() as session: user = User(username="alice", password_digest=hash_password("secret"), admin=False) session.add(user) session.flush() overlay = Overlay(name="cfgs", path=str(overlay_root), type="files", user_id=user.id) session.add(overlay) session.flush() user_id = user.id overlay_id = overlay.id client = app.test_client() with client.session_transaction() as sess: sess["user_id"] = user_id sess["pw_changed_at"] = datetime.now(UTC).isoformat() return client, overlay_id def test_edit_route_renders_full_page_without_modal_header(tmp_path, monkeypatch): client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-full.db") response = client.get(f"/overlays/{overlay_id}/files/edit?path=server.cfg") text = response.get_data(as_text=True) assert response.status_code == 200 assert "" in text.lower() # full base.html rendered assert 'href="/dashboard"' in text # nav present assert 'class="files-editor-content"' in text assert 'rcon_password' in text # content pre-filled def test_edit_route_renders_fragment_with_modal_header(tmp_path, monkeypatch): client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-fragment.db") response = client.get( f"/overlays/{overlay_id}/files/edit?path=server.cfg", headers={"HX-Modal": "1"}, ) text = response.get_data(as_text=True) assert response.status_code == 200 assert "/files/edit") @require_login def overlay_file_edit_page(overlay_id: int): """Server-rendered editor page. Renders full-page by default or as a layoutless modal fragment when the HX-Modal header is set (see the inject_base_layout context processor in app.py).""" user = current_user() assert user is not None sub_path = request.args.get("path", "") result = _load_files_overlay(overlay_id, user) if isinstance(result, Response): return result overlay = result try: target = safe_resolve_for_listing(overlay.path, sub_path) except ValueError: return Response("invalid path", status=400) if not target.exists() or not target.is_file(): return Response(status=404) if not is_editable(target): return Response("not editable", status=415) try: content = target.read_text(encoding="utf-8") except OSError: return Response("read failed", status=500) except UnicodeDecodeError: return Response("not editable", status=415) return render_template( "overlay_file_editor.html", overlay=overlay, rel_path=sub_path, content=content, byte_count=len(content.encode("utf-8")), ) ``` - [ ] **Step 4: Run tests to verify pass** Run: `cd l4d2web && uv run pytest tests/test_url_addressable_modals.py -v` Expected: 9 passed (3 from Task 1 + 6 new). - [ ] **Step 5: Smoke-test the existing test suite for regressions** Run: `cd l4d2web && uv run pytest tests/ -v --tb=short -q` Expected: all tests pass. The context processor adds `base_layout` to every template render; existing templates ignore it (they all use `{% extends "base.html" %}` literally), so behavior is unchanged. - [ ] **Step 6: Commit** ```bash git add l4d2web/l4d2web/routes/files_routes.py l4d2web/tests/test_url_addressable_modals.py git commit -m "$(cat <<'EOF' feat(modals): GET /overlays//files/edit route Server-renders the file editor as a real page. With HX-Modal:1 returns a layoutless fragment for modal embedding; without it returns the full standalone page. Mirrors overlay_file_content's path/editability checks. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 4: Persistent modal slot in base.html **Files:** - Modify: `l4d2web/l4d2web/templates/base.html` The slot is a sibling of `
`, sitting at body scope so backdrop renders over everything. - [ ] **Step 1: Add the slot and script include** In `l4d2web/l4d2web/templates/base.html`, modify the body section. After the closing `
` (currently line 39), insert the modal slot. After the ` ``` - [ ] **Step 2: Create an empty modal-router.js stub** So the new `