Two modal pipelines coexisted after the URL-addressable pilot — modal.js
(inline, ~30 lines) and modal-router.js (routed, ~150 lines) — operating
on different attribute namespaces and exposing different APIs. Future
modal authors had two systems to learn with no naming convention to
help them pick the right one for a given use case.
Consolidates both into static/js/modals.js with two clearly-named
pipelines and a single window.modals.* API:
Inline modal — content pre-rendered in the page.
Hooks: data-inline-modal-open="<dialog-id>"
data-inline-modal-close
API: window.modals.openInline(idOrEl)
window.modals.closeInline(idOrEl)
Use: confirmations, transient prompts, in-page forms without
URL value.
Routed modal — content fetched from a URL, ?modal=<path> in URL,
with history + share-link + refresh-survival.
Hooks: <a data-routed-modal href="<path>">
data-routed-modal-dismiss
API: window.modals.openRouted(path)
window.modals.closeRouted()
Use: content with standalone-page meaning.
Single document-level click delegation handles all four attribute
hooks; one DOMContentLoaded handler binds dialog 'close' / 'cancel' /
backdrop on the routed slot; shared popstate and htmx:responseError
listeners. Behaviour unchanged — pure rename + colocation.
Renamed across 11 templates and files-overlay.js. Old data-modal-*
attributes and window.openModal/closeModal globals are gone — clean
break (no back-compat shims). AGENTS.md "Modals: inline vs routed"
section documents the decision guide for new modals.
Verified: 573 backend tests pass. 5/5 Chromium smoke checks pass
(inline open/close, Esc, backdrop, routed open+save, routed Esc).
Console clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
176 lines
9.6 KiB
Markdown
176 lines
9.6 KiB
Markdown
# AGENTS.md
|
|
|
|
Guidance for coding agents working in this repository.
|
|
|
|
## Mission
|
|
|
|
Build `left4me` according to the two implementation plans:
|
|
|
|
- `docs/superpowers/plans/2026-04-22-l4d2-host-lib-v1.md`
|
|
- `docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md`
|
|
|
|
Do not invent architecture outside these plans unless explicitly requested.
|
|
|
|
## Current Project State
|
|
|
|
- `l4d2host/` and `l4d2web/` implementation directories exist.
|
|
- Implementation plans remain the source of truth for contract changes and task sequencing.
|
|
|
|
## Non-Negotiable Constraints
|
|
|
|
### Workspace and tools
|
|
|
|
- Do not use git worktrees.
|
|
- Repo is a uv workspace; Python is pinned to 3.13 via `.python-version`. After fresh checkout: install `uv` (`brew install uv` / `curl -LsSf https://astral.sh/uv/install.sh | sh`), then `direnv allow` (or `uv sync` directly). See README **Local development** for details.
|
|
|
|
### Modals: inline vs routed
|
|
|
|
Two coexisting modal mechanisms, one module (`l4d2web/l4d2web/static/js/modals.js`). When adding a new modal, decide which pipeline it belongs to:
|
|
|
|
**Inline modal** — the dialog markup is pre-rendered into the page HTML. Content is whatever's already there; the JS just calls `showModal()` / `close()` on a specific `<dialog>` by id. Use when:
|
|
- It's a confirmation (delete, overwrite, reset)
|
|
- It's a transient prompt mid-flow (conflict resolution during upload)
|
|
- It's a form whose URL state would be noise (rename, new-folder, new-server)
|
|
- The content has no standalone-page equivalent
|
|
|
|
Hooks: `<button data-inline-modal-open="<dialog-id>">` opens; `<button data-inline-modal-close>` inside the dialog closes; Esc and backdrop click also close. Programmatic: `window.modals.openInline(idOrEl)` / `window.modals.closeInline(idOrEl)`.
|
|
|
|
**Routed modal** — content is server-rendered from a URL and lands in the persistent `<dialog id="modal-container">` slot. URL gains `?modal=<path>`, refresh + share + back/forward all work. Use when:
|
|
- The content has standalone-page meaning (editor, detail view, settings panel)
|
|
- "Share this view" or "refresh-stays-here" matters
|
|
- The URL state earns its keep
|
|
|
|
Hooks: `<a data-routed-modal href="<path>">` opens (full-page nav fallback if JS fails); `<button data-routed-modal-dismiss>` inside the swapped content closes. Programmatic: `window.modals.openRouted(path)` / `window.modals.closeRouted()`.
|
|
|
|
**Conventions for routed-modal templates** (templates that `{% extends base_layout %}`, where `base_layout` resolves to `_modal_partial.html` for `HX-Modal: 1` requests and `base.html` otherwise — see `app.py:inject_base_layout`):
|
|
|
|
- **The outermost element of `{% block content %}` is a `<div>`, NOT a `<dialog>`.** The persistent slot in `base.html` already provides top-layer + backdrop + focus-trap + Esc-to-close semantics. Nested `<dialog>` collapses to 2 px in every browser.
|
|
- **Close buttons use `data-routed-modal-dismiss`** (NOT the inline-modal attribute). `modals.js` delegates at document level.
|
|
- **Form-bearing content needs document-level event delegation** for submit/save/delete, gated on `event.target.closest("#modal-content")`. Direct binding to elements in the swapped-in fragment only works in standalone mode — HTMX-swapped content arrives as fresh DOM nodes with no listeners attached. See `files-overlay.js` lines ~599-664 for the canonical pattern (read `data-*` attributes from the swapped DOM, NOT from JS state set during open).
|
|
- **CSS classes targeting modal chrome are scoped to the outer slot** — `dialog.modal, div.modal` in `components.css`. The inner content div should NOT carry `class="modal modal-wide"` (the outer dialog owns chrome; otherwise both paint card-in-a-card).
|
|
|
|
**Reference:** `docs/superpowers/specs/2026-05-17-url-addressable-modals-design.md` (design + verification matrix) and the plan errata at the top of `docs/superpowers/plans/2026-05-17-url-addressable-modals.md`.
|
|
|
|
### Dev server and filesystem paths
|
|
|
|
- **Production paths (`/var/lib/left4me`, `/usr/local/lib/systemd/system`, `/usr/local/libexec/left4me`, `/etc/left4me`) exist only on Linux deploy hosts.** Never create or write to these on a developer machine. They are referenced in `l4d2host/l4d2host/paths.py` and the spec only as the production layout.
|
|
- **For local dev, always use `scripts/dev-server.py`.** It sets `LEFT4ME_ROOT=./.tmp/dev-server`, runs migrations, seeds demo content (admin + blueprint + script overlay + files overlay), and starts Flask on port 5051. Reset state with `rm -rf .tmp/dev-server` then re-run. Never invoke `flask run` directly — that leaves `LEFT4ME_ROOT` unset and the app falls back to the production `/var/lib/left4me`, which on macOS surfaces as "route returns 404 / empty modal / file not found" and can be mistaken for a code bug.
|
|
- **All ephemeral dev state lives under `.tmp/`** (gitignored). Use `$TMPDIR` only for transient files outside the repo. Do NOT use `/tmp`, `~/Library/Application Support`, or any system path for project state — only `.tmp/` (project-local) or `$TMPDIR` (sandbox-blessed).
|
|
- **Symptom-to-cause translation:** if a route returns 404 or behaves as if the filesystem is empty, the first diagnosis is "`LEFT4ME_ROOT` is wrong" (defaulted to the production path), not "code bug." Restart via `scripts/dev-server.py`.
|
|
|
|
### Planning artifacts
|
|
|
|
- Design specs live in `docs/superpowers/specs/` as `YYYY-MM-DD-<topic>-design.md`.
|
|
- Implementation plans live in `docs/superpowers/plans/` as `YYYY-MM-DD-<topic>.md` (suffix the topic with `-v1`/`-v2`/etc. if a plan is versioned).
|
|
- Commit both to git as soon as the user approves them.
|
|
- Do not leave specs or plans outside this repo. The `~/.claude/plans/<slug>.md` plan-mode scratch file is acceptable while plan mode is open; the persisted artifact must end up under `docs/superpowers/` and be committed.
|
|
|
|
### Naming and boundaries
|
|
|
|
- Use `l4d2` naming consistently.
|
|
- Keep host library and web app as separate components.
|
|
- Do not collapse them into one package.
|
|
|
|
### Host library (`l4d2host` / `l4d2ctl`)
|
|
|
|
- Exposed CLI write command set is fixed:
|
|
- `install`
|
|
- `initialize <name> -f <spec.yaml>`
|
|
- `start <name>`
|
|
- `stop <name>`
|
|
- `delete <name>`
|
|
- CLI read commands are allowed for web/host boundary consistency:
|
|
- `status <name> --json`
|
|
- `logs <name> --lines <n> --follow/--no-follow`
|
|
- Runtime paths are rooted at `LEFT4ME_ROOT`, defaulting to `/var/lib/left4me`.
|
|
- Deployment/config management owns global units under `/usr/local/lib/systemd/system` and privileged helpers under `/usr/local/libexec/left4me`.
|
|
- Overlay directories are populated by the web app (workshop downloads, managed-global refresh). The host library only mounts them.
|
|
- Fail-fast subprocess behavior; pass raw stderr; propagate return code.
|
|
- No lock manager, no rollback, no preflight runtime checks.
|
|
- Delete missing instance/runtime dirs must succeed (no-op).
|
|
- Read APIs required for web app integration:
|
|
- `get_instance_status(name)`
|
|
- `stream_instance_logs(name, lines=200, follow=True)`
|
|
|
|
### Web app (`l4d2-web-app`)
|
|
|
|
- Flask + server-rendered templates + vendored HTMX.
|
|
- No external frontend framework/dependencies.
|
|
- Custom CSS with tokenized, consistent link and accent colors.
|
|
- Local username/password auth and `admin` flag.
|
|
- Persist command logs in `job_logs` table (retain indefinitely).
|
|
- Desired vs actual server state model.
|
|
- Live logs in UI for both jobs and servers.
|
|
- Web app host operations go through `l4d2ctl` via a host command client, not direct `l4d2host` imports.
|
|
- Blueprint semantics (locked):
|
|
- private per user in v1
|
|
- live-linked to servers
|
|
- no server-level overrides
|
|
- deleting in-use blueprint is blocked
|
|
- updates apply on next action
|
|
- servers can reassign blueprint anytime
|
|
|
|
## Delivery Workflow
|
|
|
|
1. Read both plan files fully before coding.
|
|
2. Execute plan tasks in order.
|
|
3. Keep changes scoped to one task at a time.
|
|
4. Run task-level tests before moving forward.
|
|
5. Do not claim completion without command evidence.
|
|
6. Keep docs updated when behavior/contracts change.
|
|
|
|
## Verification Expectations
|
|
|
|
Before claiming success on any step, run the relevant command and report actual output status.
|
|
|
|
Typical commands (once components exist):
|
|
|
|
- `pytest l4d2host/tests -q`
|
|
- `pytest l4d2web/tests -q`
|
|
|
|
## Change Control
|
|
|
|
- If a requested change conflicts with this file, follow explicit user instruction.
|
|
- If plans and code diverge, update plans or flag the mismatch clearly.
|
|
|
|
## End-to-end tests
|
|
|
|
The Playwright-based browser tests under `l4d2web/tests/e2e/` need a
|
|
chromium binary, fetched on first setup:
|
|
|
|
```bash
|
|
uv run playwright install chromium
|
|
```
|
|
|
|
Always invoke as `uv run pytest -m e2e ...` (excluded from the default
|
|
fast suite via the `e2e` marker). Other forms crash Chromium under the
|
|
macOS sandbox; only this exact invocation is exempt.
|
|
|
|
## Editor bundle (CodeMirror 6)
|
|
|
|
The in-browser code editor on the blueprint config / overlay script /
|
|
files-modal textareas is bundled from `l4d2web/scripts/editor-src/`
|
|
via esbuild and committed pre-built to
|
|
`l4d2web/l4d2web/static/vendor/editor.bundle.js`. Source lives under
|
|
`l4d2web/scripts/editor-src/`; design and plan at
|
|
`docs/superpowers/specs/2026-05-17-textarea-editor-v2-design.md` and
|
|
`docs/superpowers/plans/2026-05-17-textarea-editor-v2.md`.
|
|
|
|
Rebuild after editing the source:
|
|
|
|
```bash
|
|
./l4d2web/scripts/build-editor.sh
|
|
```
|
|
|
|
Requires `node` + `npm` locally. The script overrides the npm cache to
|
|
`$TMPDIR/npm-cache` (set `NPM_CACHE` to override) to dodge root-owned
|
|
files in `~/.npm/_cacache/` from older npm versions. Commit the
|
|
regenerated `editor.bundle.js`, `editor.bundle.css`, and
|
|
`editor.bundle.sha256` alongside any source change.
|
|
|
|
Regenerate the autocomplete vocab from `./cvar_list` (live L4D2
|
|
cvarlist dump committed at repo root) after replacing the dump:
|
|
|
|
```bash
|
|
./l4d2web/scripts/build-vocab.py
|
|
```
|