# URL-Addressable Modals — Design (pilot: file editor) ## Context Modals in left4me today are inline `` 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 `` 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=` 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 `
`, save button is `type="button"`, current code in `files-overlay.js` reads `window.__filesEditor.getValue()` and POSTs directly). Other inline ``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=` URL composition, `history.pushState`, `popstate` handling, `` 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: ```text 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 `` navigates to the full-page version naturally if the script fails to load. ### 2. Persistent modal slot in `base.html` ```html ``` Native `` 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 ```python # 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'} ``` ```jinja {# 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. ```text GET /overlays//files/edit?path= → load file by path from the overlay's filesystem → render templates/overlay_file_editor.html with values pre-filled: - filename input value - - 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 = ` (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 `