From d05d00449f1d0b88939d8013902199f0c3fdc7c7 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Sun, 17 May 2026 11:09:59 +0200 Subject: [PATCH] docs(modals): implementation plan for URL-addressable modals pilot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../2026-05-17-url-addressable-modals.md | 944 ++++++++++++++++++ 1 file changed, 944 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-17-url-addressable-modals.md diff --git a/docs/superpowers/plans/2026-05-17-url-addressable-modals.md b/docs/superpowers/plans/2026-05-17-url-addressable-modals.md new file mode 100644 index 0000000..de2d92b --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-url-addressable-modals.md @@ -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=` URL composition, history, and native `` open/close. HTMX (already loaded) owns fetch + swap + loading state. Jinja `inject_base_layout` context processor switches between `base.html` and `_modal_partial.html` based on the header. + +**Tech Stack:** Flask 3.x + Jinja2, HTMX 2.0.4, native ``, CodeMirror 6 (already bundled as `editor.bundle.js`), pytest for backend tests, Chromium for frontend verification. + +**Spec reference:** `docs/superpowers/specs/2026-05-17-url-addressable-modals-design.md` + +--- + +## File Structure + +| Path | New / Modify | Responsibility | +|------|--------------|----------------| +| `l4d2web/l4d2web/app.py` | Modify (insert ~5 lines after `add_template_filter`) | Register `inject_base_layout` context processor | +| `l4d2web/l4d2web/templates/_modal_partial.html` | New (1 line) | Layoutless base template — just `{% block content %}{% endblock %}` | +| `l4d2web/l4d2web/templates/overlay_file_editor.html` | New | Editor markup lifted from `overlay_detail.html:165-228`, content pre-filled, extends `base_layout` | +| `l4d2web/l4d2web/routes/files_routes.py` | Modify (add one route, ~30 lines) | `GET /overlays//files/edit?path=` | +| `l4d2web/l4d2web/templates/base.html` | Modify (insert ~3 lines) | Persistent `` slot + `modal-router.js` script include | +| `l4d2web/l4d2web/static/js/modal-router.js` | New (~60 lines) | Click intercept, URL composition, history, open/close, bootstrap | +| `l4d2web/l4d2web/static/js/editor.js` | Modify (expose `initEditors(root)`, add `htmx:afterSwap` listener) | CM6 re-init after HTMX swap | +| `l4d2web/l4d2web/static/js/files-overlay.js` | Modify (change one code path) | Replace inline-dialog populate-and-show with `window.openModal(url)` | +| `l4d2web/l4d2web/templates/overlay_detail.html` | Modify (remove `` block at lines 165-228) | Delete the old inline editor dialog | +| `l4d2web/tests/test_url_addressable_modals.py` | New | pytest coverage for context processor + new edit route | + +--- + +## Task 1: Layout context processor + partial template + +**Files:** +- Create: `l4d2web/l4d2web/templates/_modal_partial.html` +- Modify: `l4d2web/l4d2web/app.py` (insert after `app.add_template_filter(format_time_html, "timeago")` on line 62) +- Test: `l4d2web/tests/test_url_addressable_modals.py` (new) + +- [ ] **Step 1: Write the failing test** + +Create `l4d2web/tests/test_url_addressable_modals.py`: + +```python +from flask import render_template_string + +from l4d2web.app import create_app + + +def _make_app(tmp_path, monkeypatch, db_name: str): + db_url = f"sqlite:///{tmp_path/db_name}" + monkeypatch.setenv("DATABASE_URL", db_url) + return create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"}) + + +def test_base_layout_is_modal_partial_when_hx_modal_header_set(tmp_path, monkeypatch): + app = _make_app(tmp_path, monkeypatch, "layout-modal.db") + with app.test_request_context("/", headers={"HX-Modal": "1"}): + assert render_template_string("{{ base_layout }}") == "_modal_partial.html" + + +def test_base_layout_is_base_html_for_normal_request(tmp_path, monkeypatch): + app = _make_app(tmp_path, monkeypatch, "layout-default.db") + with app.test_request_context("/"): + assert render_template_string("{{ base_layout }}") == "base.html" + + +def test_base_layout_does_not_react_to_plain_hx_request_header(tmp_path, monkeypatch): + # HTMX sets HX-Request on every request including the build-status poll; + # only HX-Modal should switch the layout. + app = _make_app(tmp_path, monkeypatch, "layout-hxreq.db") + with app.test_request_context("/", headers={"HX-Request": "true"}): + assert render_template_string("{{ base_layout }}") == "base.html" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd l4d2web && uv run pytest tests/test_url_addressable_modals.py -v` + +Expected: 3 failures (all asserting that `base_layout` resolves to something — currently undefined, so render fails with `UndefinedError` or returns empty string). + +- [ ] **Step 3: Create the partial template** + +Create `l4d2web/l4d2web/templates/_modal_partial.html` with exactly this content: + +```jinja +{% block content %}{% endblock %} +``` + +- [ ] **Step 4: Register the context processor** + +In `l4d2web/l4d2web/app.py`, insert immediately after line 62 (`app.add_template_filter(format_time_html, "timeago")`): + +```python + @app.context_processor + def inject_base_layout() -> dict[str, str]: + is_modal = request.headers.get("HX-Modal") == "1" + return {"base_layout": "_modal_partial.html" if is_modal else "base.html"} +``` + +`request` is already imported at the top of the file. + +- [ ] **Step 5: Run tests to verify pass** + +Run: `cd l4d2web && uv run pytest tests/test_url_addressable_modals.py -v` + +Expected: 3 passed. + +- [ ] **Step 6: Commit** + +```bash +git add l4d2web/l4d2web/app.py l4d2web/l4d2web/templates/_modal_partial.html l4d2web/tests/test_url_addressable_modals.py +git commit -m "$(cat <<'EOF' +feat(modals): layout context processor for HX-Modal header + +Switches the Jinja base layout to _modal_partial.html (yield-only) when +the HX-Modal:1 request header is set, otherwise base.html. Foundation +for URL-addressable modals (spec 2026-05-17-url-addressable-modals). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: Editor template (file editor as standalone page) + +**Files:** +- Create: `l4d2web/l4d2web/templates/overlay_file_editor.html` +- Test: covered by Task 3's route tests (template is unreachable until then) + +This task is a lift-and-shift of the editor markup from `overlay_detail.html:165-228` into its own template with server-side content variables substituted in. + +- [ ] **Step 1: Read the source markup to lift** + +Run: `sed -n '164,228p' l4d2web/l4d2web/templates/overlay_detail.html` + +Note the surrounding `{% if files_can_edit %}` guard — that gating moves to the route (only `files` overlays expose the link). The template itself unconditionally renders the editor. + +- [ ] **Step 2: Create the new template** + +Create `l4d2web/l4d2web/templates/overlay_file_editor.html`: + +```jinja +{% extends base_layout %} +{% block title %}Edit {{ rel_path }} · {{ overlay.name }}{% endblock %} +{% block extra_head %}{% include "_editor_assets.html" %}{% endblock %} +{% block content %} + + + + + +{% endblock %} +``` + +Notes baked into the markup: +- `{% extends base_layout %}` — picks `_modal_partial.html` or `base.html` based on the request header +- `` for the full-page render — when standalone, the dialog stays open without `showModal()`. When fragment-rendered into the modal slot, `modal-router.js` calls `showModal()` on the *outer* `#modal-container` (not this inner dialog — see Task 4) +- `data-modal-dismiss` on close buttons — picked up by modal-router (deferred to Task 6) +- `data-overlay-id` + `data-rel-path` on the textarea — so the AJAX save in `files-overlay.js` can find its target without depending on global state +- Binary-file replacement UI from `overlay_detail.html:204-219` is **omitted** from this pilot template. Editable-only files reach this route (the route returns 415 for non-editable per Task 3). Binary replace stays inline-modal for now (out of pilot scope) + +- [ ] **Step 3: Commit** + +```bash +git add l4d2web/l4d2web/templates/overlay_file_editor.html +git commit -m "$(cat <<'EOF' +feat(modals): editor template that extends base_layout + +Lifts the file editor markup out of overlay_detail.html into its own +template with server-side filename, content, byte count, and download +URL pre-filled. Uses {% extends base_layout %} so the same template +renders as either a full page or a layoutless modal fragment. + +Binary replace UI deferred — pilot scope is editable text files only. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: New GET `/overlays//files/edit` route + +**Files:** +- Modify: `l4d2web/l4d2web/routes/files_routes.py` (add one route, ~35 lines) +- Test: `l4d2web/tests/test_url_addressable_modals.py` (extend) + +The route mirrors the existing `overlay_file_content` at `files_routes.py:203-234`: resolves the path, checks editability, reads UTF-8 content. Difference: returns HTML (via `overlay_file_editor.html`) instead of JSON. + +- [ ] **Step 1: Write the failing tests** + +Append to `l4d2web/tests/test_url_addressable_modals.py`: + +```python +from datetime import UTC, datetime + +from l4d2web.auth import hash_password +from l4d2web.db import init_db, session_scope +from l4d2web.models import Overlay, User + + +def _auth_client_with_files_overlay(tmp_path, monkeypatch, db_name: str): + db_url = f"sqlite:///{tmp_path/db_name}" + monkeypatch.setenv("DATABASE_URL", db_url) + app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"}) + init_db() + + overlay_root = tmp_path / "overlay_root" + overlay_root.mkdir() + (overlay_root / "server.cfg").write_text("hostname \"left4me\"\nrcon_password \"hunter2\"\n", encoding="utf-8") + + with session_scope() as session: + user = User(username="alice", password_digest=hash_password("secret"), admin=False) + session.add(user) + session.flush() + overlay = Overlay(name="cfgs", path=str(overlay_root), type="files", user_id=user.id) + session.add(overlay) + session.flush() + user_id = user.id + overlay_id = overlay.id + + client = app.test_client() + with client.session_transaction() as sess: + sess["user_id"] = user_id + sess["pw_changed_at"] = datetime.now(UTC).isoformat() + return client, overlay_id + + +def test_edit_route_renders_full_page_without_modal_header(tmp_path, monkeypatch): + client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-full.db") + response = client.get(f"/overlays/{overlay_id}/files/edit?path=server.cfg") + text = response.get_data(as_text=True) + + assert response.status_code == 200 + assert "" in text.lower() # full base.html rendered + assert 'href="/dashboard"' in text # nav present + assert 'class="files-editor-content"' in text + assert 'rcon_password' in text # content pre-filled + + +def test_edit_route_renders_fragment_with_modal_header(tmp_path, monkeypatch): + client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-fragment.db") + response = client.get( + f"/overlays/{overlay_id}/files/edit?path=server.cfg", + headers={"HX-Modal": "1"}, + ) + text = response.get_data(as_text=True) + + assert response.status_code == 200 + assert "/files/edit") +@require_login +def overlay_file_edit_page(overlay_id: int): + """Server-rendered editor page. Renders full-page by default or as a + layoutless modal fragment when the HX-Modal header is set (see the + inject_base_layout context processor in app.py).""" + user = current_user() + assert user is not None + sub_path = request.args.get("path", "") + + result = _load_files_overlay(overlay_id, user) + if isinstance(result, Response): + return result + overlay = result + + try: + target = safe_resolve_for_listing(overlay.path, sub_path) + except ValueError: + return Response("invalid path", status=400) + + if not target.exists() or not target.is_file(): + return Response(status=404) + if not is_editable(target): + return Response("not editable", status=415) + + try: + content = target.read_text(encoding="utf-8") + except OSError: + return Response("read failed", status=500) + except UnicodeDecodeError: + return Response("not editable", status=415) + + return render_template( + "overlay_file_editor.html", + overlay=overlay, + rel_path=sub_path, + content=content, + byte_count=len(content.encode("utf-8")), + ) +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `cd l4d2web && uv run pytest tests/test_url_addressable_modals.py -v` + +Expected: 9 passed (3 from Task 1 + 6 new). + +- [ ] **Step 5: Smoke-test the existing test suite for regressions** + +Run: `cd l4d2web && uv run pytest tests/ -v --tb=short -q` + +Expected: all tests pass. The context processor adds `base_layout` to every template render; existing templates ignore it (they all use `{% extends "base.html" %}` literally), so behavior is unchanged. + +- [ ] **Step 6: Commit** + +```bash +git add l4d2web/l4d2web/routes/files_routes.py l4d2web/tests/test_url_addressable_modals.py +git commit -m "$(cat <<'EOF' +feat(modals): GET /overlays//files/edit route + +Server-renders the file editor as a real page. With HX-Modal:1 returns +a layoutless fragment for modal embedding; without it returns the full +standalone page. Mirrors overlay_file_content's path/editability checks. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: Persistent modal slot in base.html + +**Files:** +- Modify: `l4d2web/l4d2web/templates/base.html` + +The slot is a sibling of `
`, sitting at body scope so backdrop renders over everything. + +- [ ] **Step 1: Add the slot and script include** + +In `l4d2web/l4d2web/templates/base.html`, modify the body section. After the closing `
` (currently line 39), insert the modal slot. After the ` + + + + + + + + +``` + +- [ ] **Step 2: Create an empty modal-router.js stub** + +So the new `