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>
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:
- Custom JS module (~50 lines) owns: click intercept on
[data-modal],?modal=<path>URL composition,history.pushState,popstatehandling,<dialog>open/close, initial-load bootstrap (parse?modal=from URL onDOMContentLoaded). - HTMX (already loaded) owns: the fetch, response swap into
#modal-content, loading indicator, error display, swap events. The JS module triggers HTMX viahtmx.ajax('GET', path, …). - Jinja layout switch owns: rendering the same route as a full page or a
layoutless fragment based on a custom
HX-Modal: 1request header. Deliberately not HTMX's built-inHX-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-Urlresponse headers and server-side URL composition usingReferer. 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 inl4d2web/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-166already stylesdialog.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.jsto exposewindow.__editor.initEditors(root)covering the existing init logic, parameterized by a DOM root (defaulting todocument). - Add an
htmx:afterSwaplistener: ifevent.target.id === 'modal-content', callwindow.__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):
- Direct link works as full page. Open
/overlays/<id>/files/edit?path=server.cfgin a new tab. Editor renders standalone with content. Save works (AJAX). - 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. - 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).
- Share URL. Copy the modal-state URL, open in incognito. Lands on the overlay page with modal already open.
- Back button. With modal open, click back. URL loses
?modal=, modal closes, underlying page intact (no flicker, polling continues). - Forward button. After back, click forward. Modal re-opens with the right file.
- Esc to close. Native
<dialog>cancelevent fires; URL clears. - Race on rapid clicks. Click edit on file A, immediately click edit on file B. Modal ends in file B's state.
- No HTMX poll misclassification. Open dev tools network panel during a modal session. Build-status polls do not carry
HX-Modal: 1and do not trigger modal-router code. - Existing inline dialogs unaffected. Rename, delete, new-folder, conflict-resolution modals still open from their
data-modal-opentriggers.
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 anhref) — kept links-only for now to preserve the "works without JS" property