left4me/docs/superpowers/plans/2026-05-17-url-addressable-modals.md
mwiegand 712ccc9861
docs(modals): plan errata — 3 verbatim-code defects + 3 inserted tasks
The URL-addressable modals plan shipped in 14 commits. Three places
where the plan's verbatim code didn't survive contact with the codebase
(has_request_context guard, LEFT4ME_ROOT-aware fixture, save-handler
direct-bind) are now documented at the top of the plan, with commit
references for the fixes. Also notes the inserted tasks 8.5/8.5b/9b
and the Task 6 design refinement (close-event single state sink) so a
future re-executor sees the actual shipped pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:03:10 +02:00

973 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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=<path>` URL composition, history, and native `<dialog>` 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 `<dialog>`, 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/<id>/`, 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 `<dialog open>` nested inside `<dialog id="modal-container">` collapses to 2 px tall in browsers. Replaced with `<div role="document">`. 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 `<div>` id from `files-editor-modal` to `files-editor-fragment` to resolve a duplicate-id W3C violation with the legacy inline `<dialog id="files-editor-modal">` 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/<id>/files/edit?path=<rel>` |
| `l4d2web/l4d2web/templates/base.html` | Modify (insert ~3 lines) | Persistent `<dialog id="modal-container">` 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 `<dialog id="files-editor-modal">` 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) <noreply@anthropic.com>
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 %}
<dialog id="files-editor-modal" class="modal modal-wide" open aria-labelledby="files-editor-title">
<div class="modal-header">
<h2 id="files-editor-title" class="files-editor-path">
<span class="files-editor-title-text">{{ rel_path }}</span>
</h2>
<button type="button" class="modal-close" data-modal-dismiss aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<label class="files-editor-field">
<span class="files-field-label">Filename</span>
<input type="text" class="files-editor-filename" data-editor-filename autocomplete="off" spellcheck="false" value="{{ rel_path }}">
</label>
<p class="files-editor-rename-hint" hidden>↻ Save will rename <code class="files-rename-from"></code><code class="files-rename-to"></code>.</p>
<div class="files-editor-text">
<label class="files-editor-field files-editor-language-field">
<span class="files-field-label">Language</span>
<select data-editor-language-select aria-label="Editor language">
<option value="auto">auto (from filename)</option>
<option value="srccfg">srccfg (.cfg)</option>
<option value="bash">bash (.sh)</option>
<option value="plain">plain</option>
</select>
</label>
<label class="files-editor-field">
<span class="files-field-label">Content</span>
<div class="editor-mount" style="--editor-rows: 14"><textarea class="files-editor-content" rows="14" spellcheck="false" data-editor-language="auto" data-overlay-id="{{ overlay.id }}" data-rel-path="{{ rel_path }}">{{ content }}</textarea></div>
</label>
<div class="files-editor-meta muted">
<span class="files-editor-byte-count">UTF-8 · {{ byte_count }} bytes</span>
<span>Ctrl+S to save</span>
</div>
</div>
</div>
<div class="modal-footer files-editor-footer">
<button type="button" class="danger-outline files-editor-delete">Delete</button>
<span class="files-editor-footer-spacer"></span>
<a class="button-secondary files-editor-download" href="/overlays/{{ overlay.id }}/files/download?path={{ rel_path|urlencode }}">⬇ Download</a>
<button type="button" class="button-secondary" data-modal-dismiss>Cancel</button>
<button type="button" class="files-editor-save">Save</button>
</div>
</dialog>
{% endblock %}
```
Notes baked into the markup:
- `{% extends base_layout %}` — picks `_modal_partial.html` or `base.html` based on the request header
- `<dialog … open>` 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) <noreply@anthropic.com>
EOF
)"
```
---
## Task 3: New GET `/overlays/<id>/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 "<!doctype html>" 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 "<html" not in text # layoutless
assert 'class="primary-nav"' not in text
assert 'class="files-editor-content"' in text
assert "hostname" in text # content pre-filled
def test_edit_route_404s_for_missing_file(tmp_path, monkeypatch):
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-404.db")
response = client.get(f"/overlays/{overlay_id}/files/edit?path=nonexistent.cfg")
assert response.status_code == 404
def test_edit_route_415s_for_non_editable_file(tmp_path, monkeypatch):
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-415.db")
# Forge a non-editable file by writing binary garbage.
with session_scope() as s:
overlay = s.query(Overlay).filter_by(id=overlay_id).one()
from pathlib import Path
Path(overlay.path).joinpath("blob.bin").write_bytes(b"\x00\x01\x02\x03" * 1024)
response = client.get(f"/overlays/{overlay_id}/files/edit?path=blob.bin")
assert response.status_code == 415
def test_edit_route_400s_for_path_traversal(tmp_path, monkeypatch):
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-400.db")
response = client.get(f"/overlays/{overlay_id}/files/edit?path=../../etc/passwd")
assert response.status_code == 400
def test_edit_route_404s_for_non_files_overlay(tmp_path, monkeypatch):
db_url = f"sqlite:///{tmp_path/'edit-script-overlay.db'}"
monkeypatch.setenv("DATABASE_URL", db_url)
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
init_db()
with session_scope() as s:
user = User(username="alice", password_digest=hash_password("secret"), admin=False)
s.add(user)
s.flush()
overlay = Overlay(name="scripted", path=str(tmp_path), type="script", user_id=user.id)
s.add(overlay)
s.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()
response = client.get(f"/overlays/{overlay_id}/files/edit?path=anything.cfg")
assert response.status_code == 404
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd l4d2web && uv run pytest tests/test_url_addressable_modals.py -v -k edit_route`
Expected: 6 failures (route doesn't exist → 404 for all).
- [ ] **Step 3: Add the route**
In `l4d2web/l4d2web/routes/files_routes.py`, append immediately after the `overlay_file_content` function (line 234):
```python
@bp.get("/overlays/<int:overlay_id>/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/<id>/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) <noreply@anthropic.com>
EOF
)"
```
---
## Task 4: Persistent modal slot in base.html
**Files:**
- Modify: `l4d2web/l4d2web/templates/base.html`
The slot is a sibling of `<main>`, 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 `</main>` (currently line 39), insert the modal slot. After the `<script src="…/modal.js">` line (currently line 43), add the modal-router include.
The body section should look like this after the edit:
```html
<main class="container">
{% block content %}{% endblock %}
</main>
<dialog id="modal-container" class="modal modal-wide" aria-labelledby="modal-content-title">
<div id="modal-content"></div>
</dialog>
<script src="{{ url_for('static', filename='vendor/htmx.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/csrf.js') }}"></script>
<script src="{{ url_for('static', filename='js/sse.js') }}"></script>
<script src="{{ url_for('static', filename='js/modal.js') }}"></script>
<script src="{{ url_for('static', filename='js/modal-router.js') }}"></script>
<script src="{{ url_for('static', filename='js/file-tree.js') }}"></script>
<script src="{{ url_for('static', filename='js/password-reveal.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/console-history.js') }}"></script>
</body>
```
- [ ] **Step 2: Create an empty modal-router.js stub**
So the new `<script src>` doesn't 404. Create `l4d2web/l4d2web/static/js/modal-router.js`:
```javascript
// URL-addressable modal router (see docs/superpowers/specs/2026-05-17-url-addressable-modals-design.md).
// Implementation lands in subsequent tasks; this stub keeps base.html's
// script include from 404'ing during the staged rollout.
(function () {
"use strict";
})();
```
- [ ] **Step 3: Run existing tests for regressions**
Run: `cd l4d2web && uv run pytest tests/test_pages.py -v -q`
Expected: all pass. The added `<dialog>` is closed by default (no `open` attribute), so it's invisible and inert.
- [ ] **Step 4: Commit**
```bash
git add l4d2web/l4d2web/templates/base.html l4d2web/l4d2web/static/js/modal-router.js
git commit -m "$(cat <<'EOF'
feat(modals): persistent modal slot + router script stub in base.html
Adds <dialog id="modal-container"> with #modal-content slot at body
scope. Script stub created so the include doesn't 404; logic follows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 5: `modal-router.js` — click intercept, open, fetch, show
**Files:**
- Modify: `l4d2web/l4d2web/static/js/modal-router.js`
This task wires the click → URL → fetch → show pipeline. Close/popstate/bootstrap come in Tasks 6 and 7.
- [ ] **Step 1: Implement click intercept + openModal + fetchAndShow**
Replace `l4d2web/l4d2web/static/js/modal-router.js` with:
```javascript
// URL-addressable modal router (see spec 2026-05-17-url-addressable-modals).
// Click intercept on a[data-modal] → ?modal=<path> in URL → htmx swap into
// #modal-content → showModal(). Close/popstate/bootstrap in later tasks.
(function () {
"use strict";
let currentModalPath = null; // race-guard against stale swaps
function openModal(path) {
const url = new URL(window.location.href);
url.searchParams.set("modal", path);
history.pushState({ modal: path }, "", url.toString());
fetchAndShow(path);
}
function fetchAndShow(path) {
currentModalPath = path;
if (typeof window.htmx === "undefined") {
console.error("[modal-router] htmx not loaded; cannot fetch modal");
return;
}
window.htmx.ajax("GET", path, {
target: "#modal-content",
swap: "innerHTML",
headers: { "HX-Modal": "1" },
}).then(() => {
// Race guard: if the user clicked again during the fetch, abandon
// this swap; the newer click will win.
if (currentModalPath !== path) return;
const dlg = document.getElementById("modal-container");
if (dlg && !dlg.open) dlg.showModal();
}).catch((err) => {
console.error("[modal-router] fetch failed", err);
});
}
document.addEventListener("click", (event) => {
const link = event.target.closest("a[data-modal]");
if (!link) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
if (event.button !== 0) return;
const href = link.getAttribute("href");
if (!href) return;
event.preventDefault();
openModal(href);
});
// Public API — used by files-overlay.js to open the editor from row clicks
// that aren't a literal <a data-modal> (existing event delegation).
window.openModal = openModal;
})();
```
- [ ] **Step 2: Chromium verification**
Steps to verify manually (or via the user's Chromium tooling). The new editor route is reachable but not yet linked from the file tree — use a temporary `<a>` for the smoke test.
1. Start the dev server: `cd l4d2web && uv run flask --app l4d2web.app:create_app run --debug --port 5000` (or whatever the project's dev-server idiom is — confirm at implementation time).
2. Log in and create a `files` overlay with a `.cfg` file in it (or use an existing one).
3. Open dev tools → Console.
4. In the console, run: `window.openModal('/overlays/<id>/files/edit?path=server.cfg')` (substitute real id).
5. **Expected:** URL gains `?modal=/overlays/<id>/files/edit?path=server.cfg`. Modal opens with the editor markup inside. (CodeMirror not yet mounted — that's Task 8 — so you'll see the raw `<textarea>` with content.)
6. Network tab: confirm the request to `/overlays/<id>/files/edit?path=server.cfg` carries the header `HX-Modal: 1`.
7. Negative check: confirm the build-status poll (`/overlays/<id>/build-status` every 2s) does **not** carry `HX-Modal: 1`.
- [ ] **Step 3: Commit**
```bash
git add l4d2web/l4d2web/static/js/modal-router.js
git commit -m "$(cat <<'EOF'
feat(modals): click intercept + openModal + fetchAndShow
a[data-modal] clicks push ?modal=<path> to URL and trigger htmx.ajax
into #modal-content with the HX-Modal header. window.openModal exposed
for non-<a> trigger sites (files-overlay row clicks). Race guard via
currentModalPath token. Close/popstate/bootstrap follow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 6: `modal-router.js` — close, popstate, dismiss, Esc
**Files:**
- Modify: `l4d2web/l4d2web/static/js/modal-router.js`
- [ ] **Step 1: Add close, popstate, dismiss-click, dialog cancel handlers**
In `modal-router.js`, replace the IIFE body with the expanded version. Insert the new function and listeners after `fetchAndShow` and before the `document.addEventListener("click", …)` for opens:
```javascript
function closeModal() {
currentModalPath = null;
const dlg = document.getElementById("modal-container");
if (dlg && dlg.open) dlg.close();
const url = new URL(window.location.href);
if (url.searchParams.has("modal")) {
url.searchParams.delete("modal");
history.pushState({}, "", url.toString());
}
}
window.addEventListener("popstate", () => {
const path = new URL(window.location.href).searchParams.get("modal");
if (path) {
fetchAndShow(path);
} else {
currentModalPath = null;
const dlg = document.getElementById("modal-container");
if (dlg && dlg.open) dlg.close();
}
});
// Dismiss triggers inside modal content
document.addEventListener("click", (event) => {
if (event.target.closest("[data-modal-dismiss]")) {
event.preventDefault();
closeModal();
}
});
// Esc key fires the dialog's 'cancel' event; sync URL.
document.addEventListener("DOMContentLoaded", () => {
const dlg = document.getElementById("modal-container");
if (dlg) {
dlg.addEventListener("cancel", (event) => {
event.preventDefault(); // prevent default close so we control URL sync
closeModal();
});
// Backdrop click — native <dialog> doesn't dismiss on backdrop; replicate.
dlg.addEventListener("click", (event) => {
if (event.target === dlg) closeModal();
});
}
});
window.closeModal = closeModal;
```
- [ ] **Step 2: Chromium verification**
1. From the prior task's setup (modal open via `window.openModal(...)`):
2. Click the `[data-modal-dismiss]` Cancel button (or the × in the modal header). **Expected:** modal closes, URL loses `?modal=…`, underlying overlay page intact and still polling build-status.
3. Open the modal again. Press Esc. **Expected:** same close behavior.
4. Open the modal again. Click on the backdrop outside the dialog content. **Expected:** same close behavior.
5. Open the modal again. Click browser back button. **Expected:** modal closes, URL clears.
6. Now click forward. **Expected:** modal re-opens with the same file's content.
- [ ] **Step 3: Commit**
```bash
git add l4d2web/l4d2web/static/js/modal-router.js
git commit -m "$(cat <<'EOF'
feat(modals): close, popstate, dismiss-click, Esc, backdrop-click
closeModal pops ?modal= from URL via pushState. popstate handler reacts
to back/forward by fetching or closing. [data-modal-dismiss] click,
native dialog 'cancel' (Esc), and backdrop click all funnel to
closeModal. window.closeModal exposed for callers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 7: `modal-router.js` — initial-load bootstrap
**Files:**
- Modify: `l4d2web/l4d2web/static/js/modal-router.js`
This makes refreshing on a `?modal=…` URL reopen the modal automatically — the headline feature.
- [ ] **Step 1: Add bootstrap on DOMContentLoaded**
Extend the existing `DOMContentLoaded` listener in `modal-router.js` (added in Task 6) so it also bootstraps from URL. Replace the block:
```javascript
document.addEventListener("DOMContentLoaded", () => {
const dlg = document.getElementById("modal-container");
if (dlg) {
dlg.addEventListener("cancel", (event) => {
event.preventDefault();
closeModal();
});
dlg.addEventListener("click", (event) => {
if (event.target === dlg) closeModal();
});
}
const initialPath = new URL(window.location.href).searchParams.get("modal");
if (initialPath) {
fetchAndShow(initialPath);
}
});
```
- [ ] **Step 2: Chromium verification**
1. Open the modal via `window.openModal('/overlays/<id>/files/edit?path=server.cfg')`.
2. Hit the browser refresh button. **Expected:** page reloads, modal re-opens automatically with the same file's content. URL retains `?modal=…`.
3. Copy the full URL. Open a new incognito window, log in, paste the URL. **Expected:** lands on the overlay page with the modal already open.
4. Negative: visit `/overlays/<id>` (no `?modal=`). **Expected:** modal does not open; underlying page renders normally.
- [ ] **Step 3: Commit**
```bash
git add l4d2web/l4d2web/static/js/modal-router.js
git commit -m "$(cat <<'EOF'
feat(modals): DOMContentLoaded bootstrap reopens modal from ?modal= URL
Refresh and share-link flows both work — the modal-state URL is the
canonical shareable artifact for "this overlay with this file open."
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 8: CodeMirror re-init after HTMX swap
**Files:**
- Modify: `l4d2web/l4d2web/static/js/editor.js`
`editor.js` currently mounts CM6 once at `DOMContentLoaded`. After modal swap-in, the new `<textarea>` is unmounted. Fix: expose `initEditors(root)` and call it from an `htmx:afterSwap` listener.
- [ ] **Step 1: Refactor `init` to accept a root**
In `l4d2web/l4d2web/static/js/editor.js`, replace the existing `init` function (currently around lines 93-100) and the bootstrap-at-end (lines 101-105) with:
```javascript
function initEditors(root) {
const scope = root || document;
for (const ta of scope.querySelectorAll("textarea[data-editor-language]")) {
mountOne(ta).catch(err => {
console.error("[editor] mount failed", err);
unhideTextarea(ta);
});
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => initEditors(document));
} else {
initEditors(document);
}
// Re-init editors that arrive via HTMX swap (modal content, etc.).
document.body.addEventListener("htmx:afterSwap", (event) => {
if (event.target && event.target.id === "modal-content") {
initEditors(event.target);
}
});
// Expose for callers that need to re-mount imperatively.
if (window.__editor) {
window.__editor.initEditors = initEditors;
}
```
- [ ] **Step 2: Chromium verification**
1. Open the editor modal via the URL flow from Task 7 (`/overlays/<id>?modal=/overlays/<id>/files/edit?path=server.cfg`).
2. **Expected:** CM6 renders inside the modal — syntax-highlighted content, NOT a raw textarea. Byte count matches actual content size. Language dropdown reflects auto-detected language (srccfg for .cfg, bash for .sh).
3. Type into the editor. **Expected:** edits are reflected; UI is responsive.
4. Close the modal, re-open. **Expected:** CM6 re-mounts cleanly each time. No duplicate editor instances visible (only one rendered).
5. Open dev tools → Network → confirm no console errors mentioning `mount failed` or duplicate-init warnings.
- [ ] **Step 3: Commit**
```bash
git add l4d2web/l4d2web/static/js/editor.js
git commit -m "$(cat <<'EOF'
feat(editor): re-init CM6 on htmx:afterSwap into #modal-content
editor.js exposes initEditors(root) and listens for htmx:afterSwap so
editor textareas that arrive via modal swap get CM6 mounted. The
DOMContentLoaded path remains for first-paint mounting.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 9: Wire file-tree edit triggers to use `window.openModal`
**Files:**
- Modify: `l4d2web/l4d2web/static/js/files-overlay.js` (specific code path; rest unchanged)
`files-overlay.js` currently populates the empty inline `#files-editor-modal` dialog when a file row is clicked. Replace that code path with a call to `window.openModal(editUrl)`. The save flow (also in this file) stays untouched.
- [ ] **Step 1: Locate the "open editor" entry point**
Run: `grep -n "files-editor-modal\|showModal\|filesEditor\|getValue\|files-editor-content" l4d2web/l4d2web/static/js/files-overlay.js`
Identify the function that handles a file-row click and currently calls `showModal()` on `#files-editor-modal`, plus the code that stuffs filename + content + language into the empty markup. That whole code path becomes a single `window.openModal(editUrl)` call.
- [ ] **Step 2: Replace the inline-dialog open path**
In that function, replace the block that populates and shows the inline dialog with:
```javascript
const editUrl = `/overlays/${overlayId}/files/edit?path=${encodeURIComponent(relPath)}`;
if (typeof window.openModal === "function") {
window.openModal(editUrl);
} else {
// Graceful fallback if modal-router didn't load — full-page navigation
// still hits the same route and renders the standalone editor page.
window.location.href = editUrl;
}
```
Delete the code that previously read the file via `/files/content` JSON endpoint and set `filenameInput.value`, the language dropdown, byte-count text, `controller.setValue(...)`, and called `showModal()`. The new route delivers all of that as server-rendered HTML.
Keep untouched:
- The **save** handler (POSTs to `/overlays/<id>/files/save` reading `window.__filesEditor.getValue()` — still works inside the modal because CM6 re-init from Task 8 sets `window.__filesEditor` on the new instance)
- The **delete** button handler (POSTs to `/overlays/<id>/files/delete`)
- The **download** link (now a server-rendered `<a href>` in the template)
- The rename hint, replace-file flow, and any other in-modal interactions — these continue to bind on the editor element inside `#modal-content` via the existing event delegation
- [ ] **Step 3: Chromium verification**
1. On `/overlays/<id>` (a `files` overlay's page), click an editable file (e.g. `server.cfg`).
2. **Expected:** URL updates to `?modal=/overlays/<id>/files/edit?path=server.cfg`. Modal opens with CM6 editor, content pre-filled, language auto-detected.
3. Edit content and click **Save**. **Expected:** save succeeds (network request to `/overlays/<id>/files/save` returns 200), file persists.
4. Refresh the page (still on the `?modal=` URL). **Expected:** modal reopens with the *saved* (updated) content.
5. Click Cancel. **Expected:** modal closes; URL loses `?modal=`.
6. Race test: click file A, then immediately click file B before A's swap arrives. **Expected:** modal ends in file B's state, not file A's.
- [ ] **Step 4: Commit**
```bash
git add l4d2web/l4d2web/static/js/files-overlay.js
git commit -m "$(cat <<'EOF'
feat(files): file-row click opens editor via URL-addressable modal
files-overlay.js no longer fetches /files/content JSON and populates
the inline <dialog>; it calls window.openModal(<edit-url>) which the
modal-router handles end-to-end. Save flow unchanged — CM6 re-init on
htmx:afterSwap re-binds window.__filesEditor on the new instance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 10: Remove the dead inline editor dialog + final verification
**Files:**
- Modify: `l4d2web/l4d2web/templates/overlay_detail.html` (delete lines 164-228 — the `{% if files_can_edit %} <dialog id="files-editor-modal">…</dialog> {% endif %}` block)
- Modify: `l4d2web/tests/test_url_addressable_modals.py` (optional: add a test that the inline dialog is gone)
- [ ] **Step 1: Write a "dialog removed" assertion test**
Append to `l4d2web/tests/test_url_addressable_modals.py`:
```python
def test_overlay_detail_no_longer_includes_inline_editor_dialog(tmp_path, monkeypatch):
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "no-inline-dialog.db")
response = client.get(f"/overlays/{overlay_id}")
text = response.get_data(as_text=True)
assert response.status_code == 200
# The inline editor dialog is replaced by the URL-addressable route.
assert 'id="files-editor-modal"' not in text
# The persistent modal-container slot from base.html *is* present.
assert 'id="modal-container"' in text
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd l4d2web && uv run pytest tests/test_url_addressable_modals.py::test_overlay_detail_no_longer_includes_inline_editor_dialog -v`
Expected: FAIL — `id="files-editor-modal"` is still in `overlay_detail.html`.
- [ ] **Step 3: Remove the inline dialog**
In `l4d2web/l4d2web/templates/overlay_detail.html`, delete lines 164-228 inclusive — the entire `{% if files_can_edit %} … <dialog id="files-editor-modal"> … </dialog> … {% endif %}` block.
- [ ] **Step 4: Run all backend tests for regressions**
Run: `cd l4d2web && uv run pytest tests/ -v --tb=short -q`
Expected: all tests pass. The new assertion passes; nothing else regresses.
- [ ] **Step 5: Run the full Chromium verification matrix from the spec**
Walk through all 10 checks from `docs/superpowers/specs/2026-05-17-url-addressable-modals-design.md` ## Verification:
1. Direct link works as full page — paste `/overlays/<id>/files/edit?path=server.cfg` in a new tab, no `?modal=`, full-page editor renders, save works.
2. Modal open from overlay — click edit in the file tree, modal opens, URL gets `?modal=`.
3. Refresh in modal state — F5 reopens modal on the same overlay with build-status polling resumed.
4. Share URL — paste in incognito, lands with modal open.
5. Back button — closes modal, URL clears, underlying page intact.
6. Forward button — reopens modal with same content.
7. Esc to close — URL syncs.
8. Race on rapid clicks — final state is the last-clicked file.
9. No HTMX poll misclassification — build-status polls don't carry `HX-Modal:1`.
10. Existing inline dialogs unaffected — rename, delete, new-folder, conflict-resolution still open from `[data-modal-open]` triggers (these don't use `[data-modal]`).
- [ ] **Step 6: Commit**
```bash
git add l4d2web/l4d2web/templates/overlay_detail.html l4d2web/tests/test_url_addressable_modals.py
git commit -m "$(cat <<'EOF'
feat(modals): remove inline editor dialog, complete pilot migration
overlay_detail.html no longer carries the empty <dialog
id="files-editor-modal"> placeholder — content lives at
/overlays/<id>/files/edit?path=… and renders via the URL-addressable
modal pipeline. Pilot complete; spec follow-ups (save→hx-post, other
modals, server-side URL composition) deferred.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Self-review notes (against the spec)
- **Architecture (Approach C):** Tasks 5, 6, 7 implement the JS module exactly as specified — ~60 lines including comments and exposed API.
- **Layout switch via `HX-Modal: 1` header:** Task 1 implements it as a context processor; Task 1's third test explicitly guards against misclassifying HTMX's built-in `HX-Request` header.
- **`<dialog>` for show/hide:** Task 4 adds the persistent slot; Task 5 uses `showModal()`; Tasks 6/7 use `close()` and native `cancel` event.
- **Editor as a real page:** Tasks 2 + 3 cover this — separate template, separate route, dual-mode rendering.
- **CodeMirror re-init:** Task 8 covers `initEditors(root)` exposure + `htmx:afterSwap` listener.
- **Save flow stays AJAX:** Task 9 preserves the save path while replacing the *open* path.
- **Race guard, dismiss attrs, Esc, backdrop click, popstate, bootstrap:** all in Tasks 57.
- **Out of scope items** (binary replace, other modals, save-flow migration, server-side URL composition): not touched by any task — matches the spec's deferral.
No placeholders, no `TODO`s, no "implement appropriately." Every step has exact paths and exact code.