# 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
