Step 5/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.
Adds a server-rendered editor page for creating a new file in a target
folder. Renders the same overlay_file_editor.html as the edit route,
but with is_new=True so the template:
* Hides the Delete button
* Hides the Download link (no file to download yet)
* Changes the Save button label to "Create"
* Emits data-at-folder on the textarea for the JS save handler to
compose path = at_folder + "/" + filename
* Updates the title block (browser tab + heading) to "New file" /
"<at_folder>/…new file"
The existing edit route now passes is_new=False and at_folder=""
explicitly so both call sites are explicit about the contract.
Path validation: ?at may be empty (overlay root) or a relative folder
path. safe_resolve_for_listing rejects traversal attempts (400). A
missing or non-directory at returns 404. Reused the helper to keep
the safety story identical to the listing / edit routes.
Phase B Step 5 is server-side only — no JS changes here. Step 6
migrates openEditorTextNew in editor.js to use this route via
window.modals.openRouted().
Tests added (tests/test_url_addressable_modals.py):
* test_new_route_renders_with_empty_content
* test_new_route_renders_with_target_folder_attribute
* test_new_route_renders_create_button_not_save
* test_new_route_400s_for_invalid_at_path
* test_new_route_404s_for_non_files_overlay
pytest: 573 → 578 passed, 1 skipped, 3 deselected.
Verified live: curl /overlays/2/files/new returns markup with
data-at-folder="", "UTF-8 · 0 bytes" byte count, save button label
"Create", and no Delete / Download buttons.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 4/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.
End of Phase A. files-overlay.js is now a 19-line tombstone comment;
all behavior lives in the 4 modules under files-overlay/. Step 10
deletes the stub and its <script> tag.
uploads.js owns:
* The upload queue (concurrency 3, XHR-based progress, queued/active/
done/cancelled/error/conflict states) and the progress panel
* Drag-drop on treeRoot (5 events: dragstart, dragend, dragover,
dragleave, drop). Internal drags (row → folder, via the custom
application/x-files-overlay MIME) and external drags (OS files +
folders via webkitGetAsEntry) flow through the same drop handler.
* walkEntry for recursive folder walks during external drops
* withCollisionSuffix (moves here from files-overlay.js — its biggest
caller is the upload-conflict "keep both" path; exposed on
__filesOverlay for editor.js's save-409 path too)
* "zip" action handler (registered into __filesOverlay) — pure URL
navigation; placed here as the closest thematic home
Pattern change: upload-row cancel buttons converted from direct-bound
per row (inside buildUploadRow, which captured `item` in a closure) to
a single document-level delegated click listener. Each row carries
data-upload-id; an uploads Map<uploadId, item> looks up the item at
click time. The Map entry is removed in the uploadsClearBtn handler
when a done/error/cancelled row is cleared, so the Map doesn't grow
unbounded.
Drag-drop stays direct-bound to treeRoot per the plan escape hatch —
the 5 events share coordinated highlight state (is-drag-source,
is-drop-target classes that are toggled across events), and treeRoot
is persistent (never swapped). Delegation would obscure the state
coordination logic without any real benefit.
Phase A end-state:
* core.js (247 lines): helpers, manager guard, registry dispatch
* editor.js (550 lines): editor flows (legacy + URL-addressable)
* dialogs.js (212 lines): new-folder, delete-confirm, conflict
* uploads.js (423 lines): upload queue + drag-drop + zip + collision
* files-overlay.js (19 lines): tombstone comment, deleted in Step 10
Total: ~1432 lines across 4 modules + 19-line stub. The plan estimated
~780 lines across 4 modules; actual is ~1.8× larger, the difference
being module-header comments and the delegation-with-state scaffolding
(e.g., editor.js's dual-editor listener split, dialogs.js's per-dialog
state plus close-event resolvers).
Verified live on /overlays/2 in Chromium:
* 5 script tags load in document order (core → editor → dialogs →
uploads → legacy stub)
* Registry has 10 keys; askConflict and withCollisionSuffix are both
callable; withCollisionSuffix('foo.tar.gz') === 'foo.tar (1).gz'
(legacy behavior preserved — lastIndexOf('.') splits before the
final extension)
* Uploads panel + list elements present in DOM, panel hidden by
default
* "zip" action button exists; registered handler would set
window.location.href to /overlays/2/files/download_zip?path=...
* No console errors
* pytest still 573 passed, 1 skipped, 3 deselected
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 3/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.
dialogs.js owns the three inline <dialog> modals that surround the
file manager:
* #files-new-folder-modal — "+ folder" / mkdir prompt
* #files-delete-modal — delete-confirm for files and folders
* #files-conflict-modal — overwrite / keep-both / cancel choice
Pattern change: the legacy file used clone-and-rebind
(replaceWith(cloneNode(true)) + fresh addEventListener) to drop stale
state-bearing click listeners between dialog opens. dialogs.js replaces
that with a single delegated listener per dialog, reading per-dialog
state from module-scope nullable variables (conflictState,
deleteState, newFolderState). The state is set when the dialog opens
and cleared on the 'close' event so dismissals don't leave stale
references. Listener attaches once per page load.
Dialog opens go through window.modals.openInline()/closeInline()
instead of dialog.showModal()/close() directly, completing the inline-
modal convention from commit c51089d.
askConflict now resolves to "cancel" on any dismissal (Esc, backdrop,
programmatic close) thanks to the 'close' event handler — the legacy
version left the promise pending forever in those paths. Verified
live: closeInline() on an open conflict dialog resolves the pending
askConflict promise to "cancel".
Action-registry dispatch: dialogs.js registers "new-folder" and
"delete" handlers into __filesOverlay. Combined with editor.js's
registration of "new-file" and "edit" (Step 2), only "zip" remains in
the legacy click switch (pure URL navigation, no module dependency).
Cross-module exposure: askConflict moves from files-overlay.js to
dialogs.js; both set __filesOverlay.askConflict, but dialogs.js wins
by document order (it loads before legacy via the <script defer>
ordering in overlay_detail.html). The legacy upload + drag-drop call
sites switch from local askConflict() to window.__filesOverlay
.askConflict() — same shape, different lookup.
The orphaned newFolderDialog / conflictDialog / deleteDialog
declarations at the top of legacy are deleted; legacy no longer holds
references to those elements.
Numbers:
files-overlay.js: 669 → 589 lines (-80)
files-overlay/dialogs.js: 212 lines (new)
Net: +132 lines. Growth is from the delegation/state-management
scaffolding and module-header comments. The delete went lighter
than the plan's ~150-line estimate because the new code is more
carefully structured (less duplication across the 3 dialogs).
Verified live on /overlays/2 in Chromium:
* 4 script tags load in order (core → editor → dialogs → legacy)
* Registry has 10 keys; askConflict + withCollisionSuffix still set
* "+ new folder" on overlay root → new-folder dialog opens with
empty name input and "/" target label, closes cleanly
* "✕" on a file row → delete-confirm dialog opens with the file's
name displayed, closes cleanly
* askConflict('a/b.txt') → conflict dialog opens with path shown
- close via window.modals.closeInline() → resolves "cancel"
- click [data-files-conflict-action="overwrite"] → resolves "overwrite"
- click [data-files-conflict-action="keep-both"] → resolves "keep-both"
* No console errors throughout
* pytest still 573 passed, 1 skipped, 3 deselected
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 2/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.
editor.js is dual-purpose during Phase A: drives both the legacy
inline #files-editor-modal <dialog> (binary-replace + create-new flows)
and the URL-addressable modal swapped into #modal-content (editable
text files). Phase B migrates the legacy flows to URL-addressable too
and removes the legacy branches.
What moved:
* Editor state object, editorEls DOM refs, CM6 bridge (getEditorValue,
setEditorValue), UI helpers (setEditorTitle, updateByteCount,
updateRenameHint, updateSaveEnabled, setQueuedReplacement)
* openEditorTextNew (create-new file flow)
* openEditorForFile (legacy binary + editable-as-fallback flow)
* All save/delete/replace handlers — converted from direct-bound on
editorEls.{saveBtn,deleteBtn} to a single document-level click
listener that discriminates by ancestor (legacy editorDialog vs.
URL-addressable #modal-content)
* Replace-zone dragover/dragleave/drop — direct-bound on
editorEls.replaceZone → document-level delegation gated on the zone
being inside the legacy dialog
* Replace-input change, replace-clear / replace-browse clicks — also
delegated
* The previously-separate URL-addressable save/delete delegation
block (lines 593-664 of the legacy file) collapses into the same
delegated listeners
What stays direct-bound (per plan escape hatch):
* input on .files-editor-filename
* input + keydown on .files-editor-content (Ctrl+S handling)
* close on the persistent legacy <dialog>
These are high-frequency events on persistent inputs inside the
persistent legacy dialog; delegation would add per-keystroke
selector-matching overhead with no benefit.
Action dispatch: editor.js registers "new-file" and "edit" handlers
into __filesOverlay (set up by core.js). The legacy switch-case in
files-overlay.js's click delegation loses both cases — they're now
dispatched via the registry. The legacy switch still owns new-folder,
zip, and delete (those migrate in Step 3).
Cross-module exposure: askConflict and withCollisionSuffix stay in
files-overlay.js (the upload queue and drag-drop code at lines 857
and 974 still use them) and are exposed on __filesOverlay so editor.js
can call them. They migrate to dialogs.js (askConflict, Step 3) and
uploads.js (withCollisionSuffix, Step 4); the call sites in editor.js
don't change.
Numbers:
files-overlay.js: 1091 → 669 lines (-422)
files-overlay/editor.js: 550 lines (new)
Net: +128 lines; the growth is from the dual-editor delegation
scaffolding (separate handler functions for legacy vs. routed) and
module-header comments. The legacy file is now a stub editor section
comment plus the unmigrated dialogs/uploads/drag-drop blocks.
Verified live on /overlays/2 in Chromium:
* 3 script tags load in document order (core → editor → legacy)
* window.__filesOverlay registry now has 10 keys (added askConflict +
withCollisionSuffix); withCollisionSuffix('foo.txt') = 'foo (1).txt'
* No console errors on page load or after synthetic actions
* E2E dispatch check: clicking a "+ new file" action button opens the
legacy dialog with empty filename + Create save-button label
(proves core → handleAction → editor.js handler → openEditorTextNew
chain works)
* E2E dispatch check: clicking the filename button on an editable
file sets ?modal=%2Foverlays%2F2%2Ffiles%2Fedit%3Fpath%3D... in the
URL (proves editor.js's "edit" handler correctly routes editable
files through window.modals.openRouted)
* pytest still 573 passed, 1 skipped, 3 deselected
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 1/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.
Pure scaffolding — no behavior change. core.js is loaded with defer in
overlay_detail.html before the existing files-overlay.js script tag
(both with defer, so execution order follows document order). Both
files run the same .files-manager guard, both attach document-level
click listeners on the action selector. The legacy file's switch-case
still owns dispatch; core.js's listener dispatches into an empty
registry until Steps 2–4 populate handlers.
window.__filesOverlay exposes:
* manager / overlayId / baseUrl / treeRoot / csrfToken (manager-
element-derived state, computed once)
* helpers.{joinPath, parentOf, basename, escapeHtml, humanSize,
fetchJson, postJson, postForm, refreshFolder, findRowByPath,
cssEscape, scheduleRefresh} (duplicated from legacy file for the
duration of Phase A; de-duplicates as feature modules migrate out)
* registerHandler(op, fn) / handleAction(op, path, actionEl) — the
action-dispatch registry that Steps 2–4 populate
Per the canonical plan's errata commit (d76ee05), the script tag goes
in overlay_detail.html (not base.html as the original plan said) and
uses defer to match the existing pattern.
Verified live on /overlays/2 in Chromium: both <script> tags present
in DOM order; window.__filesOverlay shape matches expectation (8
top-level keys, 12 helpers); overlayId="2", baseUrl="/overlays/2",
treeRoot resolved; joinPath('foo/','/bar') === 'foo/bar' smoke test
passes; no console errors; existing 3-file/1-folder tree still
renders. pytest still 573 passed, 1 skipped, 3 deselected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Read-only file explorers (server detail page, read-only overlay) used
to omit the hover-action panel entirely and make the filename itself
the download link. Editable overlays did the opposite: hover-action
panel with download + delete, filename-click opens the editor.
Unify both surfaces around the editable pattern: always emit the
hover-action span when any action applies. Gate the download button
on download_supported only (now visible on all surfaces). Keep
delete + folder actions (+file, +folder, zip) gated on files_overlay,
since read-only surfaces never offer those. In read-only mode, the
filename becomes a plain <span> — the hover ⬇ is the single download
affordance, matching editable mode's filename-click ≠ hover-download
split.
Prefactor for the files-overlay.js rewrite (docs/superpowers/plans/
2026-05-17-files-overlay-rewrite.md). No JS changes; files-overlay.js
doesn't run on read-only surfaces (manager-element guard at line 23-24).
Verified: pytest stayed at 573/1/3 (URL substrings still appear in the
rendered HTML even though they moved out of the filename anchor into
the hover-action span). Direct Jinja render confirmed all four
branches: read-only downloadable file (new hover ⬇, plain <span>
filename), broken read-only symlink (no hover panel — correct, can't
download dangling links), read-only download_supported=False (no
hover panel, plain <span>), editable mode (byte-identical to before).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
files-overlay.js is loaded from overlay_detail.html:285 (with defer),
not base.html — the JS activates only when .files-manager exists,
which is only on overlay detail for files-type overlays. Loading from
base.html would pull it onto every page. The plan's first draft had
this wrong in four places (step 1, step 4, step 10, critical files
table). Following the plan verbatim would have moved the script tag
to the wrong template — exactly the failure mode that
feedback_validate_before_implementing memory warns about.
Added an Errata section at the bottom of the plan documenting this.
Also clarified that all new module script tags should use defer to
match the existing pattern (the modules query the DOM at load and
need the body parsed first).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three-phase plan to consolidate files-overlay.js's mixed event-binding
patterns and complete the URL-addressable modals migration:
Phase A (4 commits): split the 35 KB IIFE into 4 focused modules
under static/js/files-overlay/ — core, editor, dialogs, uploads —
with consistent document-level delegation. Behavior unchanged.
Phase B (6 commits): migrate the two remaining inline-dialog flows
(create-new-file, binary-replace) to URL-addressable modals via a
new /files/new route and a binary-mode branch in the edit route +
template. Delete the legacy <dialog id="files-editor-modal"> from
overlay_detail.html. editor.js becomes single-purpose (~200 lines).
Phase C (2 commits): extract shared path/editability helper for
routes/files_routes.py; delete the now-unused /files/content JSON
endpoint; consolidate save/replace rename duplication.
Each commit is independently verifiable + revertable. Natural pause
points at the end of each phase. Plan is the handoff artifact for
cross-session execution.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Architectural problem flagged after the pilot: "the template renders both
as a standalone page AND as a modal fragment" contract is non-obvious for
future template authors. Task 2 originally used <dialog>, Task 8.5 had to
undo that because nested <dialog> collapses to 2px. The convention is now
in two places:
1. AGENTS.md gains a "URL-addressable modal templates" section under
Non-Negotiable Constraints listing: outer element must be <div>, close
buttons use data-modal-dismiss, form actions need #modal-content-scoped
document delegation, modal chrome CSS is owned by the outer slot.
2. _modal_partial.html (the file template authors will most likely open
when wondering "what's this layout?") carries a Jinja comment header
summarising the rule + linking to AGENTS.md for the full convention.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The URL-addressable modals plan shipped in 14 commits. Three places
where the plan's verbatim code didn't survive contact with the codebase
(has_request_context guard, LEFT4ME_ROOT-aware fixture, save-handler
direct-bind) are now documented at the top of the plan, with commit
references for the fixes. Also notes the inserted tasks 8.5/8.5b/9b
and the Task 6 design refinement (close-event single state sink) so a
future re-executor sees the actual shipped pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small follow-ups flagged during code review of Tasks 4 and 9:
1. <dialog id="modal-container" aria-labelledby="modal-content-title">
referenced an id that never existed. Removed the attribute; the
inner modal content provides its own aria-labelledby on the heading,
and screen readers traverse dialog content reasonably without an
outer label.
2. The new editor template's outer <div> shared id="files-editor-modal"
with the legacy inline <dialog> in overlay_detail.html — duplicate
id when the modal is open, W3C-invalid (though functionally inert).
Renamed the new div to id="files-editor-fragment" and broadened
editor.js's closest() selector to match both, so auto-language
detection works for both the legacy and the new modal pipelines.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 9's new save delegation read only the editor content, not the
filename input — so typing a new filename and clicking Save silently
discarded the rename and wrote to the original path. Matches the
legacy save handler's payload.new_path contract: if the user edited
the filename, compose new_path = parent/filename and send it. 409
conflict (destination exists) shows an alert and keeps the modal
open so the user can adjust.
Also exposes rawText in fetchJson return so plain-text server error
messages (e.g. "destination already exists") reach the alert call.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
files-overlay.js no longer fetches /files/content JSON and populates
the inline <dialog>; it calls window.openModal(<edit-url>) which the
modal-router handles end-to-end. Binary files retain the old inline
dialog path (binary replace deferred from pilot scope).
Added document-level event delegation for .files-editor-save and
.files-editor-delete inside #modal-content so save/delete work on the
server-rendered editor DOM (reads data-rel-path from the textarea;
calls window.__filesEditor.getValue() for content; calls closeModal()
on success). The inline dialog's own save/delete handlers are untouched
and continue to serve the create-new-file and binary-replace flows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The outer <dialog id="modal-container" class="modal modal-wide"> already
provides border, background, max-width, and padding. After Task 8.5
broadened the CSS to also match div.modal, the inner div was painting
its own card chrome inside the outer one — card-in-a-card visual.
Strip class="modal modal-wide" and role="document" from the inner div.
Standalone-mode renders the editor as flat content under <main> (the
"this URL is also a real page" promise; full-page = full-page, not a
modal-over-nothing).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three coupled lifecycle bugs surfaced during Task 5-8 reviews:
1. overlay_file_editor.html emitted a <dialog open> that nested inside
the outer <dialog id="modal-container">, collapsing the modal to
2px tall. Replaced with <div role="document" aria-labelledby="…">
so a11y semantics survive and the layout actually renders.
2. modal-router.js's close-event handler now tears down CM6 controllers
via controller.destroy() and clears #modal-content innerHTML, fixing
a real leak (each open/close cycle was orphaning an EditorView and
a matchMedia "change" listener on window).
3. mountOne in editor.js now short-circuits if the textarea already has
a controller, defending against future double-mount paths.
CSS: added div.modal and div.modal.modal-wide selectors alongside the
existing dialog.modal ones so the editor <div> gets correct styling.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
editor.js exposes initEditors(root) and listens for htmx:afterSwap so
editor textareas that arrive via modal swap get CM6 mounted. The
DOMContentLoaded path remains for first-paint mounting.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refresh and share-link flows both work — the modal-state URL is the
canonical shareable artifact for "this overlay with this file open."
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Centralizes state cleanup on the dialog's native 'close' event: every
close source (Esc cancel event, backdrop click, [data-modal-dismiss],
browser back, htmx:responseError on the modal fetch, or programmatic
closeModal()) just calls dialog.close() and the single 'close' listener
clears ?modal= from the URL and resets currentModalPath. This avoids
the trap where legacy modal.js's backdrop close didn't sync our URL,
and the trap where a 4xx response opened an empty modal.
window.closeModal exposed for callers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Production paths (/var/lib/left4me, /usr/local/...) exist only on Linux
deploy hosts. Local dev must use scripts/dev-server.py which sets
LEFT4ME_ROOT=./.tmp/dev-server and seeds demo content. Running plain
"flask run" leaves LEFT4ME_ROOT unset and the app falls back to the
production path, surfacing as 404s on what looks like code bugs.
Adds symptom-to-cause guidance so future agents diagnose "route 404 in
dev" as a config issue, not a code defect.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
a[data-modal] clicks push ?modal=<path> to URL and trigger htmx.ajax
into #modal-content with the HX-Modal header. window.openModal exposed
for non-<a> trigger sites (files-overlay row clicks). Race guard via
currentModalPath token. Close/popstate/bootstrap follow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds <dialog id="modal-container"> with #modal-content slot at body
scope. Script stub created so the include doesn't 404; logic follows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Server-renders the file editor as a real page. With HX-Modal:1 returns
a layoutless fragment for modal embedding; without it returns the full
standalone page. Mirrors overlay_file_content's path/editability checks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lifts the file editor markup out of overlay_detail.html into its own
template with server-side filename, content, byte count, and download
URL pre-filled. Uses {% extends base_layout %} so the same template
renders as either a full page or a layoutless modal fragment.
Binary replace UI deferred — pilot scope is editable text files only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switches the Jinja base layout to _modal_partial.html (yield-only) when
the HX-Modal:1 request header is set, otherwise base.html. Foundation
for URL-addressable modals (spec 2026-05-17-url-addressable-modals).
Guards with has_request_context() so the processor is safe when
render_template_string is called from app_context() without a request
(e.g. test_timeago_filter_registered_on_app).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Spec for the swift3-style ?modal=<path> pattern: same route renders full page
or layoutless fragment based on an HX-Modal header, ~50-line JS module owns
URL+history, HTMX owns fetch+swap, native <dialog> owns show/hide. Pilot
migrates the file editor's open/render flow only — save flow stays AJAX.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the dedicated edit button with a click target on the filename
itself (download stays as a separate ⬇ action). Drops margin-left:auto on
.files-row-actions so action buttons sit immediately after the row's name
instead of at the far right.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CLS verified zero (0.00000) on /blueprints/1 and /overlays/1 via
PerformanceObserver({type: 'layout-shift', buffered: true}) on a
real browser session — previously CLS=0.00859 from a 253 px shift
when cm6 mounted into a display:none slot.
Mechanism:
- editor-entry.js: mount() accepts `rows`. When provided, prepends
an EditorView.theme that pins
.cm-editor { height: calc(rows * 1.84rem + 1.125rem) }
and sets .cm-scroller overflow:auto. cm6 renders at a fixed,
predictable height; long content scrolls internally (same UX the
raw <textarea rows="N"> used to give).
- editor.js: reads textarea.rows attribute and passes it to mount().
- editor.css: new .editor-mount wrapper uses the same calc on
min-height keyed off an inline --editor-rows CSS custom property,
so the slot is pre-reserved BEFORE cm6 mounts. Wrapper and cm6
match exactly (browser-measured 254 / 254 px for rows=8 and
607 / 607 px for rows=20).
- Templates: each editor textarea wrapped in
<div class="editor-mount" style="--editor-rows: N">. Single source
of truth on N (only the rows attribute + the inline custom prop
vary per call site).
Per-row metric 1.84 rem derived empirically: 253 px for rows=8 minus
1.125 rem chrome = 235 px content, ÷ 8 ≈ 29.4 px = 1.84 rem.
Fast suite + e2e suite still green (3 + 2 pass, 0 fail).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous flicker fix hid the textarea via CSS but display: none
removes it from layout entirely — so the page rendered with zero
height where the editor would go, then cm6 mounted and pushed the
surrounding form down by its full height (CLS).
Wrap each editor textarea in <div class="editor-mount" style="min-height: …rem">
so the slot is reserved before cm6 mounts. The wrapper is a flex
column with cm6 as flex: 1 so cm6 fills the reserved space rather
than collapsing to content-height with a gap below (the seeded
blueprint has 2 chars of content; without flex the editor would
shrink to one line).
Min-heights calibrated to rows × ~1.25rem + ~1.5rem chrome:
- config (rows=8) → 12rem
- files (rows=14) → 19rem
- script (rows=20) → 27rem
.cm-editor's own min-height: 8em rule removed — the wrapper is the
floor now, and the inner cm6 stretches to fill via flex.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three changes that together stop the page from briefly painting the
raw textareas before cm6 takes over:
1. base.html gains a {% block extra_head %}{% endblock %} hook.
2. blueprint_detail.html and overlay_detail.html include
_editor_assets.html via that extra_head block instead of inside
{% block content %}. Editor CSS now loads from <head>, so the
textarea pre-hide rule (added below) applies before first paint;
the defer'd scripts also download in parallel with HTML parse,
which is the better default anyway.
3. editor.css adds
textarea[data-editor-language] { display: none; }
so opt-in textareas are hidden from the very first paint.
editor.js + _editor_assets.html cover the three paths the pre-hide
must not break:
- bundle didn't load: top-of-IIFE bails early and un-hides every
matching textarea via style.display = "revert".
- per-textarea mount throws: init()'s catch un-hides that specific
textarea so the form stays usable.
- JS disabled entirely: _editor_assets.html ships a <noscript>
<style> override that un-hides via display: revert.
Fast suite + e2e suite both still green (676 + 3 pass, 0 fail).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an 'Editor bundle (CodeMirror 6)' section after the e2e tests
section describing:
- where the source lives (l4d2web/scripts/editor-src/)
- how to rebuild (./l4d2web/scripts/build-editor.sh)
- the NPM_CACHE workaround for the root-owned ~/.npm cache files
- the vocab regeneration command (./l4d2web/scripts/build-vocab.py)
- pointers to the design + plan docs
Verified end-to-end:
- 676 fast tests passing (no regressions from the wiring)
- 3 e2e tests passing (smoke + 2 editor tests)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two e2e tests:
- test_blueprint_autocomplete_accept_writes_into_hidden_textarea:
loads /blueprints/1, types 'sv_che', asserts the cm6 autocomplete
popup shows 'sv_cheats', presses Tab to accept, fires a synthetic
submit on the form, and reads the hidden textarea value back.
Exercises both the autocomplete extension and the submit-time copy
bridge in editor.js end-to-end.
- test_copy_preserves_newlines_across_lines: regression gate for
bug class 1 from v1 (Prism+contenteditable collapsed multi-line
selections). cm6 preserves linebreaks in its doc by construction;
we verify via the per-textarea controller's getValue().
editor-entry.js: discovered during the e2e debug that cm6's default
completionKeymap does NOT bind Tab. Added an explicit
`{ key: "Tab", run: acceptCompletion }` ahead of the rest of the
keymap stack so Tab accepts when the popup is open and falls through
to indentWithTab otherwise. Bundle rebuilt + SHA refreshed.
Tests also surfaced a 200ms popup-settle timing race: the popup is
*visible* on the same tick acceptCompletion runs against null
selectedCompletion. A page.wait_for_timeout(200) before pressing
the accept key bridges the gap reliably in CI.
Chromium runs fine in Claude Code's default sandbox — the stale note
in the handoff doc about Mach-port IPC sandbox-blocking is no longer
accurate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Routes 8 call sites through the cm6 controller alias:
- 5 reads (byte-count, save POST, dirty checks at 306, 481, 496, 511,
528) → getEditorValue() helper, falling back to
editorEls.contentBox.value if window.__filesEditor isn't mounted
(no-JS / pre-mount path).
- 3 writes (clear, "Loading…" placeholder, fetched body content at
362, 395, 402) → setEditorValue() helper with the same fallback.
The two helpers live inline next to editorEls so the rest of the
module's call sites stay close to existing style.
Known regressions (out of scope for v2, candidate follow-ups):
- Byte-count badge updates only on file-open / setContent calls, not
live on every keystroke. Needs a controller.onChange(cb) hook.
- Ctrl+S inside cm6 doesn't trigger the modal Save. cm6 owns the
keymap in its editing surface; users can still click the Save
button. Adding a custom cm6 keymap entry would restore the
shortcut.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New test_blueprint_config_form_post_round_trip — POSTs a multi-line
config, GETs the page, asserts each line re-renders inside the
textarea. Pins the round-trip the v2 editor's submit-time copy
handler must preserve before any template wiring lands.
Skipped a corresponding test_overlay_script_form_post_contract test
— the existing test_admin_creates_system_wide_script_overlay at
test_script_overlay_routes.py:~270 already asserts
overlay.script == "echo admin" after a POST /overlays/<id>/script,
which is the same form-contract pin. YAGNI; no need to duplicate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five-line partial included on every page that mounts an editor.
Two <link> stylesheets (vendor + glue) and two nonce'd <script>
tags (bundle + glue). The `defer` attribute preserves document
order, so editor.bundle.js (which assigns window.__editor)
executes before editor.js (which reads it).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Un-bundled progressive-enhancement glue:
- DOMContentLoaded → mount cm6 on every textarea[data-editor-language].
- Each <form> gets one capture-phase submit handler that copies every
contained editor's getValue() into its textarea.value before the
browser serializes the form (submit-time copy bridge).
- The textarea with class files-editor-content (the files-modal
textarea) exposes its controller as window.__filesEditor for
files-overlay.js's getValue / setContent / setLanguage calls.
- 'auto' language resolves from the modal's filename input
([data-editor-filename]); a language [data-editor-language-select]
dropdown lets the user override.
- Vocab fetched lazily on the first srccfg mount; cached for the page.
Falls through silently if window.__editor isn't defined (bundle
failed to load), keeping the raw textarea visible — no-JS fallback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tokens.css gains:
- --syntax-{keyword,string,comment,number}: source-of-truth syntax
token colors, overridden in the prefers-color-scheme: dark block.
- --cm-{bg,fg,keyword,string,comment,number,selection}: bridge
variables the cm6 themes (themes.js) reference. --cm-bg / --cm-fg
route through the existing --color-surface / --color-text palette
so they pick up dark-mode automatically.
editor.css scopes the cm6 shell (.cm-editor) to match the app's
existing --line / --radius-s / --color-focus tokens. Token colors
themselves come from cm6 themes, not this stylesheet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
build-editor.sh runs npm install + esbuild from editor-src/, produces:
- editor.bundle.js 324.6 KB minified IIFE, sets window.__editor.mount
- editor.bundle.css 0 B placeholder (cm6 injects styles at runtime
via StyleModule; future extensions that need real CSS can drop into
the same file without a template change)
- editor.bundle.sha256 integrity hashes
The script uses $TMPDIR/npm-cache (override via NPM_CACHE env var)
to work around root-owned files in the default ~/.npm cache from
older npm versions (the env's `npm ci` rejected the default cache).
vendor/README.md documents the rebuild command, the cache override,
and the integrity-record convention.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the Task 1 stub. Builds an EditorView with:
- history, line numbers, active-line highlight, bracket matching,
close brackets, indent-on-input
- default + custom HighlightStyle
- light/dark theme via matchMedia-driven Compartment with a
prefers-color-scheme change listener
- language via Compartment (swappable for the files-modal dropdown)
- autocomplete via Compartment (only if vocab is provided)
- keymap stack: closeBrackets, default, history, completion, indentWithTab
Mounts the EditorView immediately before the textarea, hides the
textarea. Exposes window.__editor.mount(textarea, opts) returning a
controller with getValue / setContent / setLanguage / destroy.
bash language comes via @codemirror/legacy-modes/mode/shell wrapped
in StreamLanguage.define — same mechanism as srccfg.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CompletionSource over the srccfg-vocab.json shape. Word fragment
matched via /[A-Za-z0-9_]{2,}/ at the caret; ranking is
prefix-match-first (shorter prefixes preferred) then substring;
cap 50 candidates, top 8 rendered. Each option carries the kind
('cvar'/'command') as cm6's autocomplete `type` so the popup
shows the appropriate icon.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
themes.js exports four extensions:
- editorLightTheme / editorDarkTheme: EditorView.theme() variants
keyed to the --cm-* CSS variables defined in tokens.css (light) and
its prefers-color-scheme: dark block.
- editorHighlightStyle: HighlightStyle bound to Lezer tags
(comment, string, number, keyword, variableName).
- editorHighlighting: syntaxHighlighting(editorHighlightStyle) ready
to drop into the EditorState extensions array.
@lezer/highlight comes in transitively via @codemirror/language;
no new package.json dependency needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
~30 LOC StreamLanguage definition for Source-engine .cfg syntax.
Tokens: line comment (//…), string, number, keyword (exec/alias/bind/
unbindall/wait), identifier. Linewise, no nesting — matches the
shape we authored as a Prism regex grammar in the v1 attempt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
build-vocab.py parses ./cvar_list (live L4D2 cvarlist dump, 2196 entries)
into static/data/srccfg-vocab.json — 1523 cvars + 671 commands.
Idempotent. Records the source-file SHA256 in the JSON header so
regenerations are auditable.
cvar_list is committed as a tracked data file so the generation is
reproducible from the repo alone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@codemirror/lang-bash is not an official package. cm6's official path
to bash highlighting is @codemirror/legacy-modes/mode/shell wrapped in
StreamLanguage.define(), matching the same mechanism we use for the
custom srccfg mode.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>