diff --git a/docs/superpowers/specs/2026-05-17-url-addressable-modals-design.md b/docs/superpowers/specs/2026-05-17-url-addressable-modals-design.md new file mode 100644 index 0000000..7f030b8 --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-url-addressable-modals-design.md @@ -0,0 +1,236 @@ +# 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 `