diff --git a/docs/superpowers/plans/2026-05-08-overlay-file-tree.md b/docs/superpowers/plans/2026-05-08-overlay-file-tree.md
index f56561d..158e71f 100644
--- a/docs/superpowers/plans/2026-05-08-overlay-file-tree.md
+++ b/docs/superpowers/plans/2026-05-08-overlay-file-tree.md
@@ -15,7 +15,7 @@ See the design doc for rationale. Implementation-relevant summary:
- New blueprint `files_bp` registered in `l4d2web/app.py` next to `overlay_bp`.
- Path resolution chains through `l4d2host.paths.overlay_path()` (already validates the overlay ref + resolves under `LEFT4ME_ROOT/overlays/`) and `l4d2web.services.security.validate_overlay_ref` (rejects empty/`.`/`..`/absolute/whitespace/backslash for the sub-path component).
- Listing rule: target must be a descendant of `overlay_root` after `Path.resolve()`. Download rule: real path must be a descendant of `LEFT4ME_ROOT` after `os.path.realpath()`.
-- Tree shape: single recursive partial. `_overlay_file_tree.html` renders `
`; `_overlay_file_node.html` renders one folder or file `
`. Folder buttons carry `hx-get`, `hx-target="next .file-tree-children"`, `hx-swap="innerHTML"`, `hx-trigger="click once"`. Subsequent toggles handled by `static/js/file-tree.js`.
+- Tree shape: single recursive partial. `_overlay_file_tree.html` renders `
`; `_overlay_file_node.html` renders one folder or file `
`. Folder buttons carry `data-files-url="/overlays/{id}/files?path=…"`. `static/js/file-tree.js` handles every click — toggles `aria-expanded` + `hidden`, fetches once on first expand, dedupes rapid clicks via `dataset.loaded`.
- `DEFAULT_MAX_ENTRIES = 500` in the helper module; re-resolved per call so tests can monkeypatch.
- No changes to `l4d2host`, builders, or workshop/script edit flows.
@@ -119,13 +119,13 @@ pytest l4d2web/tests/ -q # no regressions across the full suite
**Files:**
-- Create: `l4d2web/static/js/file-tree.js` — 12-line event-delegated `click` handler that toggles `aria-expanded` on `.file-tree-toggle` and `hidden` on the next `.file-tree-children` sibling.
+- Create: `l4d2web/static/js/file-tree.js` — event-delegated `click` handler that toggles `aria-expanded` on `.file-tree-toggle` and `hidden` on the next `.file-tree-children` sibling, and on first expand fires `fetch(button.dataset.filesUrl)` and innerHTMLs the response. `dataset.loaded` flag dedupes rapid clicks; cleared on error to allow retry.
- Modify: `l4d2web/templates/base.html` — `` next to the existing `csrf.js` / `sse.js` / `modal.js` lines.
- Modify: `l4d2web/static/css/components.css` — append `~50` lines: `.file-tree`, `.file-tree-row`, `.file-tree-toggle` (transparent button, inherits color), `.file-tree-toggle .chevron` rotation transform on `aria-expanded="true"`, `.file-tree-children[hidden]`, `.file-tree-badge` + `.file-tree-badge-warn`. All against existing tokens (`--space-xs`, `--space-l`, `--color-surface-muted`, `--color-muted`, `--color-danger`, `--radius-s`).
**Implementation:**
-- `hx-trigger="click once"` makes HTMX fire the GET only on the first expansion; the JS handler fires on every click and toggles aria/hidden state. First click: HTMX populates `.file-tree-children`, JS toggles to expanded. Subsequent clicks: only JS fires, no re-fetch.
+- The JS handler fires on every click. First-expand path: read `button.dataset.filesUrl`, set `dataset.loaded="1"` optimistically, `fetch(url, {credentials: "same-origin"})`, replace `.file-tree-children` innerHTML with the response. Subsequent clicks just toggle `aria-expanded` + `hidden` — no re-fetch since `dataset.loaded` is set. On fetch error: `delete dataset.loaded` so a future click retries.
- The CSS chevron is a Unicode `›` inside a ``; rotated 90° on expanded via `transform: rotate(90deg)` with a 120ms transition.
**Verification:**
diff --git a/docs/superpowers/specs/2026-05-08-overlay-file-tree-design.md b/docs/superpowers/specs/2026-05-08-overlay-file-tree-design.md
index e0c1e5b..161b21d 100644
--- a/docs/superpowers/specs/2026-05-08-overlay-file-tree-design.md
+++ b/docs/superpowers/specs/2026-05-08-overlay-file-tree-design.md
@@ -1,8 +1,8 @@
# Overlay File Tree Section Design
-**Goal:** Add a "Files" section to the overlay detail page (`/overlays/`) that renders a collapsible tree of the overlay's runtime directory at `${LEFT4ME_ROOT}/overlays/{overlay.id}/`, with HTMX-driven lazy expansion of folders and click-to-download for individual files. Same access rule as the rest of the overlay detail page (admin or `overlay.user_id == g.user.id`). Read-only; no rename/delete/upload in v1.
+**Goal:** Add a "Files" section to the overlay detail page (`/overlays/`) that renders a collapsible tree of the overlay's runtime directory at `${LEFT4ME_ROOT}/overlays/{overlay.id}/`, with lazy expansion of folders (one fetch per first-time expand) and click-to-download for individual files. Same access rule as the rest of the overlay detail page (admin or `overlay.user_id == g.user.id`). Read-only; no rename/delete/upload in v1.
-**Approval status:** User-approved 2026-05-08 (visual companion brainstorm + plan-mode review). Implemented + deployed in the same session.
+**Approval status:** User-approved 2026-05-08 (visual companion brainstorm + plan-mode review). Implemented + deployed in the same session. The lazy-load originally targeted HTMX (vendored in `base.html`), but the post-deploy smoke uncovered that `static/vendor/htmx.min.js` was a 33-byte placeholder — the real library was never vendored. Rather than vendoring full HTMX for one feature, the lazy-load was switched to plain JS using the same fetch + innerHTML pattern (~30 lines in `static/js/file-tree.js`). The route + partial contracts are unchanged.
## Context
@@ -10,13 +10,11 @@ Today, the overlay detail page shows the row's metadata (name, type, scope, path
Click-to-download is a secondary nice-to-have: workshop overlays' `addons/*.vpk` are absolute symlinks into the shared `${LEFT4ME_ROOT}/workshop_cache/`, and pulling a single VPK to a dev box otherwise means scping with the right path translation.
-This is also the codebase's **first HTMX-driven feature**: HTMX is vendored in `base.html` but not previously used in any template. The route + partial + tiny event-delegated JS established here become the reusable pattern for future HTMX features.
-
## Locked Decisions
1. **All overlay types show the section.** Script + workshop + system/managed. Consistency over a tighter scope; even workshop's predictable `addons/*.vpk` layout is worth confirming.
-2. **Collapsible tree, lazy load on expand.** Tree can get large; only the root level is rendered server-side at first paint. Each folder click fires `GET /overlays//files?path=` and swaps in that folder's children. The HTMX-disabled / no-JS path still shows the root level (the same partial is server-rendered).
-3. **`hx-trigger="click once"` + JS for re-toggle.** First click does the GET (HTMX disables further fetches via `once`); every subsequent click runs through `static/js/file-tree.js`, an event-delegated handler that toggles `aria-expanded` on the button and `hidden` on the next `.file-tree-children` sibling.
+2. **Collapsible tree, lazy load on expand.** Tree can get large; only the root level is rendered server-side at first paint. Each folder click fires `GET /overlays//files?path=` and innerHTMLs the response into that folder's `.file-tree-children` div. The no-JS path still shows the root level (the same partial is server-rendered) — folders just won't expand.
+3. **Single delegated JS handler.** `static/js/file-tree.js` listens for `click` on `document`, finds the closest `.file-tree-toggle` button, toggles `aria-expanded` + `hidden`, and on first expand fires a `fetch()` against the URL in the button's `data-files-url`. Subsequent toggles never re-fetch (`button.dataset.loaded` flag, set optimistically before the fetch to dedupe rapid clicks; cleared on error to allow retry).
4. **Single-file download in v1.** No bulk archive (e.g., "download whole overlay as `.tar.gz`"). Files are streamed via Flask `send_file(..., as_attachment=True)`. No size cap — VPKs are commonly 100–500 MB and that's the whole point.
5. **No auto-refresh.** The tree reflects what was on disk at page render. After a build, the user reloads the page. Polling/SSE would duplicate the existing live-log mechanism on the build-job page for negligible benefit.
6. **Same access rule as the rest of the page.** `g.user.admin or overlay.user_id is None or overlay.user_id == g.user.id`. GETs need no CSRF (`l4d2web/app.py:56`).
@@ -60,15 +58,15 @@ GET /overlays//files/download?path= (files_routes.overlay_files_d
### File tree fragment shape
-`_overlay_file_tree.html` produces a `
` containing one `_overlay_file_node.html` row per entry plus an optional truncated-footer `