left4me/AGENTS.md
mwiegand e1723f751c
docs(agents): update modal-pattern reference + add files-overlay layout
Two updates to AGENTS.md after the files-overlay rewrite:

1. The "canonical pattern" reference at the bottom of the inline-vs-
   routed modals section pointed at files-overlay.js lines ~599-664.
   That file is gone. Updated the reference to point at the new
   single-purpose location: editor.js's document-level click listener
   and the routedSaveClicked / routedReplaceClicked / routedDeleteClicked
   functions.

2. Added a "Files overlay: module layout" subsection right after the
   modals one. Names the four modules under static/js/files-overlay/,
   what each owns, the window.__filesOverlay action-registry contract,
   and the recipe for adding a new file-row action. Future agents
   touching the file manager hit a one-paragraph orientation instead
   of re-deriving the layout from git log.

No behavior change. The "Modals: inline vs routed" decision tree
itself stays accurate post-rewrite: editor is routed; new-folder,
delete-confirm, conflict stay inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:20:23 +02:00

221 lines
12 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 `static/js/files-overlay/editor.js`'s document-level click listener + the `routedSaveClicked` / `routedReplaceClicked` / `routedDeleteClicked` functions 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`.
### Files overlay: module layout
The file-manager JS for files-type overlays is split across four
modules under `l4d2web/l4d2web/static/js/files-overlay/`, all loaded
with `defer` from `templates/overlay_detail.html`. They cooperate via
the `window.__filesOverlay` action registry that `core.js` sets up:
- **`core.js`** — manager-element detection (`.files-manager` guard),
derived state (`overlayId`, `baseUrl`, `treeRoot`, `csrfToken`),
shared helpers (`joinPath`, `parentOf`, `basename`, `humanSize`,
`fetchJson`, `postJson`, `postForm`, `refreshFolder`,
`findRowByPath`, `cssEscape`, `scheduleRefresh`), and the
document-level click listener that dispatches `[data-action]`
clicks through `__filesOverlay.handleAction(op, path, actionEl)`
into per-feature handlers.
- **`editor.js`** — URL-addressable editor only. Handles the new-file
route (`/files/new?at=...`), edit route for text + binary
(`/files/edit?path=...`), and the save / replace / delete delegated
click handlers scoped to `#modal-content`. Registers `"new-file"`
and `"edit"` into the registry.
- **`dialogs.js`** — the three inline `<dialog>` modals (new-folder,
delete-confirm, conflict). Module-scope state per dialog (one
delegated listener each, no clone-and-rebind). Exposes
`askConflict(path) → Promise<"overwrite"|"keep-both"|"cancel">`
on `__filesOverlay` for use by editor.js + uploads.js. Registers
`"new-folder"` and `"delete"` into the registry.
- **`uploads.js`** — upload queue (concurrency 3, XHR-based progress,
`data-upload-id` delegated cancel), drag-drop on `treeRoot`
(direct-bound — 5 coordinated events share highlight state), and
the `"zip"` registry handler. Exposes
`withCollisionSuffix(path) → suffixedPath` for the upload + save
conflict paths. Drag-drop on `treeRoot` is the **only** direct-bound
listener block in the four modules; everything else is document-level
delegation (see escape-hatch comments in-source).
When adding a new file-row action, the contract is:
1. Render the `<button data-action="my-op" data-target-path="...">` in
`templates/_overlay_file_node.html` (gated on the right capability
flag).
2. Pick the module that owns the action and register a handler:
`fo.registerHandler("my-op", (path, actionEl) => { ... })`.
3. The dispatch wiring in `core.js` takes care of catching the click
and calling the handler. No new listeners needed.
### 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
```