docs(modals): implementation plan for URL-addressable modals pilot
10-task TDD plan: context processor + partial → editor template → GET /files/edit route → modal slot + script stub → modal-router.js (click+fetch+show → close+popstate+dismiss → bootstrap) → CM6 re-init → files-overlay.js wiring → remove inline dialog + Chromium matrix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fcab4b0b72
commit
d05d00449f
1 changed files with 944 additions and 0 deletions
944
docs/superpowers/plans/2026-05-17-url-addressable-modals.md
Normal file
944
docs/superpowers/plans/2026-05-17-url-addressable-modals.md
Normal file
|
|
@ -0,0 +1,944 @@
|
||||||
|
# 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`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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">×</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 5–7.
|
||||||
|
- **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.
|
||||||
Loading…
Reference in a new issue