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

44 KiB
Raw Blame History

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:

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:

{% 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")):

    @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
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:

{% 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

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:

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):

@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
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:

    <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:

// 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
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:

// 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
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:

  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
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:

  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
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:

  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
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:

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
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:

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
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 TODOs, no "implement appropriately." Every step has exact paths and exact code.