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>
This commit is contained in:
mwiegand 2026-05-17 11:03:46 +02:00
parent 2942467cfd
commit fcab4b0b72
No known key found for this signature in database

View file

@ -0,0 +1,236 @@
# 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:
```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 `<a href>` navigates to the full-page version naturally if the script
fails to load.
### 2. Persistent modal slot in `base.html`
```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
```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/<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