left4me/docs/superpowers/specs/2026-05-17-url-addressable-modals-design.md
mwiegand fcab4b0b72
docs(modals): URL-addressable modals design (pilot: file editor)
Spec for the swift3-style ?modal=<path> pattern: same route renders full page
or layoutless fragment based on an HX-Modal header, ~50-line JS module owns
URL+history, HTMX owns fetch+swap, native <dialog> owns show/hide. Pilot
migrates the file editor's open/render flow only — save flow stays AJAX.

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

11 KiB

URL-Addressable Modals — Design (pilot: file editor)

Context

Modals in left4me today are inline <dialog> elements pre-rendered into every page that needs them, opened/closed via JS only. There is no URL state, no deep linking, no "share this view" affordance, and content is populated by client-side state — for the file editor specifically, l4d2web/l4d2web/static/js/files-overlay.js fills an empty <dialog id="files-editor-modal"> template (at l4d2web/l4d2web/templates/overlay_detail.html:165-228) from data already on the page.

We want the swift3 (Rails) pattern: clicking a modal trigger appends ?modal=<path> to the current URL, the modal content is server-rendered from that path with a layoutless template, and the same URL works as a standalone full page on refresh or direct visit. Underlying-page identity is preserved, which matters here because overlay pages have live-updating regions (build status polled every 2 s, console transcripts) we don't want to lose when someone shares a link with a modal open.

Pilot scope: the file editor only. Its open/render flow migrates to URL-addressable; its save flow stays AJAX (no <form>, save button is type="button", current code in files-overlay.js reads window.__filesEditor.getValue() and POSTs directly). Other inline <dialog>s (rename, delete, new folder, conflict-resolution) stay as they are. The new system is additive and lives at a different attribute selector ([data-modal]) than the existing inline-dialog triggers ([data-modal-open] / [data-modal-close]), so they coexist without collision.

Architecture — Approach C (Hybrid)

Three layers with clear boundaries:

  1. Custom JS module (~50 lines) owns: click intercept on [data-modal], ?modal=<path> URL composition, history.pushState, popstate handling, <dialog> open/close, initial-load bootstrap (parse ?modal= from URL on DOMContentLoaded).
  2. HTMX (already loaded) owns: the fetch, response swap into #modal-content, loading indicator, error display, swap events. The JS module triggers HTMX via htmx.ajax('GET', path, …).
  3. Jinja layout switch owns: rendering the same route as a full page or a layoutless fragment based on a custom HX-Modal: 1 request header. Deliberately not HTMX's built-in HX-Request, which fires on every HTMX request including the existing 2-second build-status poll — using it would misclassify polls as modal renders.

Why C and not A (HTMX-native) or B (port swift3 verbatim)

  • A depends on HX-Push-Url response headers and server-side URL composition using Referer. That's fragile on POST redirects and on first-load bootstrap — the server doesn't reliably know the "underlying" URL.
  • B reimplements fetch/swap/loading/error machinery HTMX already provides (~150 lines), gaining only HTMX-independence (hypothetical value).
  • C isolates the one thing HTMX can't do well (compose a URL whose path stays put while a query param records the modal target) into the smallest possible module, and lets HTMX do the rest. URL composition is local, deterministic, and obviously correct.

C is also a stepping stone to A if we ever decide server-side URL composition is preferable; going A → C means rewriting URL state.

Components

1. modal-router.js (new, ~50 lines)

Two attribute systems coexist deliberately:

  • [data-modal] on links — the new URL-addressable system (this module)
  • [data-modal-open] / [data-modal-close] on buttons — the existing inline dialog triggers in l4d2web/l4d2web/static/js/modal.js (unchanged, keeps working)

Module responsibilities:

click on a[data-modal] (left-click only, no modifier keys)
  → preventDefault
  → openModal(href)

openModal(path)
  → url.searchParams.set('modal', path)
  → history.pushState({modal: path}, '', url)
  → fetchAndShow(path)

fetchAndShow(path)
  → htmx.ajax('GET', path, {target:'#modal-content', headers:{'HX-Modal':'1'}})
  → on success: document.getElementById('modal-container').showModal()
  → guard against stale responses (track currentModalPath token; discard
    swaps whose target path no longer matches)

closeModal()
  → dialog.close()
  → url.searchParams.delete('modal')
  → history.pushState({}, '', url)
  → (deferred — pilot doesn't require it) optional refresh of underlying page
    content via htmx.ajax

popstate
  → re-read ?modal= from URL
  → if set: fetchAndShow; if absent: dialog.close()

DOMContentLoaded
  → if ?modal= present: fetchAndShow

click on [data-modal-dismiss]
  → closeModal()

dialog 'cancel' event (Esc key, native)
  → closeModal()  (so the URL syncs)

Links-only (not buttons): preserves the "works without JS" property — a plain <a href> navigates to the full-page version naturally if the script fails to load.

2. Persistent modal slot in base.html

<dialog id="modal-container" class="modal modal-wide">
  <div id="modal-content"></div>
</dialog>

Native <dialog> chosen over swift3's CSS-class approach because it:

  • gets free focus trap, Esc-to-close, backdrop click behavior
  • matches the existing modal markup pattern in left4me
  • the existing CSS at l4d2web/l4d2web/static/css/components.css:114-166 already styles dialog.modal

3. Jinja layout switch via context processor

# l4d2web/l4d2web/app.py (or wherever the Flask app is constructed)
@app.context_processor
def inject_base_layout():
    is_modal = request.headers.get('HX-Modal') == '1'
    return {'base_layout': '_modal_partial.html' if is_modal else 'base.html'}
{# templates/_modal_partial.html — single line #}
{% block content %}{% endblock %}

{# Any page template (e.g. the new editor route) #}
{% extends base_layout %}
{% block content %}…the page content…{% endblock %}

Same route, two render modes, zero per-route changes after a template is updated to use {% extends base_layout %}.

4. New server route: file editor as a real page

The dominant cost of the pilot. The current editor markup is empty and populated by files-overlay.js. For URL deep-linking to mean anything on refresh, the editor route must server-render the markup with content pre-filled.

GET /overlays/<overlay_id>/files/edit?path=<rel_path>
  → load file by path from the overlay's filesystem
  → render templates/overlay_file_editor.html with values pre-filled:
     - filename input value
     - <textarea data-editor-language="auto">{{ content }}</textarea>
     - byte count, modification time
     - download href
  → if request.headers['HX-Modal'] == '1':
       extends _modal_partial.html → returns the dialog body fragment only
     else:
       extends base.html → returns full standalone page (overlay nav + editor)

This template is a lift-and-shift of overlay_detail.html:165-228 into its own template file, with content variables substituted in instead of empty placeholders.

files-overlay.js becomes simpler: the "open editor" code path now becomes just location.href = <edit-url> (which the modal-router intercepts and turns into the modal flow). Save flow is unchanged — still calls the existing AJAX POST endpoint reading window.__filesEditor.getValue().

5. CodeMirror re-init after HTMX swap

l4d2web/l4d2web/static/js/editor.js currently mounts CM6 at DOMContentLoaded only (see the if (document.readyState === "loading") branch at the end of the file). After the editor HTML is swapped in via HTMX, the new <textarea data-editor-language> is in the DOM but unmounted. Fix:

  • Refactor editor.js to expose window.__editor.initEditors(root) covering the existing init logic, parameterized by a DOM root (defaulting to document).
  • Add an htmx:afterSwap listener: if event.target.id === 'modal-content', call window.__editor.initEditors(event.target).
  • Add a cleanup hook: when the modal closes, call controller.destroy() on the CM6 instance to free listeners (the editor bundle should already expose this on the controller; verify at implementation time).

Critical files to modify

Path Change
l4d2web/l4d2web/static/js/modal-router.js new — the ~50-line module
l4d2web/l4d2web/static/js/editor.js expose initEditors(root), add htmx:afterSwap listener
l4d2web/l4d2web/static/js/files-overlay.js change "open editor" path to set location.href (intercepted) instead of populating the inline dialog
l4d2web/l4d2web/templates/base.html add persistent #modal-container slot; include modal-router.js
l4d2web/l4d2web/templates/_modal_partial.html new — single-line layout
l4d2web/l4d2web/templates/overlay_file_editor.html new — lifted from overlay_detail.html:165-228 with content variables
l4d2web/l4d2web/templates/overlay_detail.html remove the inline <dialog id="files-editor-modal"> (it is now a route)
l4d2web/l4d2web/routes/files_routes.py add GET /overlays/<id>/files/edit
l4d2web/l4d2web/app.py (or app-factory location) add the inject_base_layout context processor

Verification

End-to-end checks (manual + Chromium):

  1. Direct link works as full page. Open /overlays/<id>/files/edit?path=server.cfg in a new tab. Editor renders standalone with content. Save works (AJAX).
  2. Modal open from overlay. From /overlays/<id>, click a file's edit link. URL becomes /overlays/<id>?modal=/overlays/<id>/files/edit?path=server.cfg. Modal opens with content. CM6 mounted, language detected from filename, byte count correct.
  3. Refresh in modal state. With modal open, hit refresh. Same modal opens, same content, on the same underlying overlay page (which has resumed its 2-second build-status polling).
  4. Share URL. Copy the modal-state URL, open in incognito. Lands on the overlay page with modal already open.
  5. Back button. With modal open, click back. URL loses ?modal=, modal closes, underlying page intact (no flicker, polling continues).
  6. Forward button. After back, click forward. Modal re-opens with the right file.
  7. Esc to close. Native <dialog> cancel event fires; URL clears.
  8. Race on rapid clicks. Click edit on file A, immediately click edit on file B. Modal ends in file B's state.
  9. No HTMX poll misclassification. Open dev tools network panel during a modal session. Build-status polls do not carry HX-Modal: 1 and do not trigger modal-router code.
  10. Existing inline dialogs unaffected. Rename, delete, new-folder, conflict-resolution modals still open from their data-modal-open triggers.

Out of scope (follow-ups)

  • Migrating the editor save flow from AJAX to hx-post + HX-Redirect (a real <form>)
  • Migrating other modals (rename, delete, new-folder, conflict) to the URL-addressable pattern
  • Server-side URL composition (Approach A migration) — only if a concrete need emerges
  • [data-modal] on non-<a> elements (buttons that trigger modals without an href) — kept links-only for now to preserve the "works without JS" property