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

12 KiB

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 slotdialog.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:

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:

./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:

./l4d2web/scripts/build-vocab.py