Commit graph

267 commits

Author SHA1 Message Date
mwiegand
4d045e578d
feat(files): migrate create-new-file JS flow to URL-addressable modal
Step 6/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.

Two changes in editor.js:

1. The "new-file" registered handler now opens the URL-addressable
   modal at GET /overlays/<id>/files/new?at=<folder> via
   window.modals.openRouted, replacing the call to openEditorTextNew.
   The legacy openEditorTextNew function stays in this file for now —
   it's no longer reachable from a user action; Step 9 deletes it
   alongside the rest of the legacy dialog block.

2. routedSaveClicked gains an is_new branch. When the textarea's
   data-rel-path is empty, the save composes the new file's path from
   data-at-folder (set by the /files/new route) + the user-typed
   filename and POSTs {path, content} to /files/save. The /save
   endpoint creates the file when it doesn't exist; 409 means a file
   at that path already exists and the user picks a different name
   (alert + modal stays open so the form value is preserved).

The legacy slash-in-filename guard from openEditorTextNew's legacy
save path is deliberately not carried over — the plan permits typing
"sub/foo.txt" in the filename input to create a nested file via
/save, matching the route's path semantics.

Verified live on /overlays/2 in Chromium:
  * Click "+ new file" on overlay root → URL becomes
    ?modal=%2Foverlays%2F2%2Ffiles%2Fnew%3Fat%3D
  * Routed modal opens with empty data-rel-path, empty data-at-folder,
    empty filename input, save button labeled "Create", no Delete or
    Download buttons, title "…new file"
  * No console errors
  * pytest still 578 passed, 1 skipped, 3 deselected (no Python or
    test changes in Step 6)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:05:55 +02:00
mwiegand
6b0231970c
feat(files): add GET /overlays/<id>/files/new + extend editor template
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>
2026-05-17 16:03:01 +02:00
mwiegand
cb391ad456
feat(files): migrate uploads + drag-drop to uploads.js; legacy file is a stub
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>
2026-05-17 15:57:30 +02:00
mwiegand
307df9c23a
feat(files): migrate dialogs (new-folder, delete, conflict) to dialogs.js
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>
2026-05-17 15:51:47 +02:00
mwiegand
f094eca074
feat(files): migrate editor handlers to files-overlay/editor.js
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>
2026-05-17 15:42:58 +02:00
mwiegand
052ddcb4f0
feat(files): scaffold files-overlay/core.js with helpers + action registry
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>
2026-05-17 15:22:54 +02:00
mwiegand
4fa39642b0
refactor(files): collapse files_overlay binary mode into per-capability gates
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>
2026-05-17 15:19:00 +02:00
mwiegand
c51089df1b
refactor(modals): consolidate modal.js + modal-router.js as inline/routed
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>
2026-05-17 14:31:38 +02:00
mwiegand
74fd906cf4
docs(modals): codify URL-addressable modal template convention
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>
2026-05-17 14:09:05 +02:00
mwiegand
55c7856eb1
fix(modals): drop dangling aria-labelledby + rename inner id
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>
2026-05-17 14:03:09 +02:00
mwiegand
33a2e529f6
fix(files): support rename-on-save in URL-addressable modal
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>
2026-05-17 13:29:46 +02:00
mwiegand
64cf203890
feat(files): file-row click opens editor via URL-addressable modal
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>
2026-05-17 13:13:38 +02:00
mwiegand
7829d1ca95
fix(modals): drop double-card chrome from inner editor div
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>
2026-05-17 13:07:28 +02:00
mwiegand
f6b8ecfd5d
fix(modals): nested-dialog rendering, CM6 destroy on close, mount idempotency
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>
2026-05-17 12:58:41 +02:00
mwiegand
f426970d4c
feat(editor): re-init CM6 on htmx:afterSwap into #modal-content
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>
2026-05-17 12:43:12 +02:00
mwiegand
afd2ed1c3c
feat(modals): DOMContentLoaded bootstrap reopens modal from ?modal= URL
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>
2026-05-17 12:36:04 +02:00
mwiegand
6e66375233
feat(modals): close, popstate, dismiss, Esc, backdrop, response-error
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>
2026-05-17 12:28:36 +02:00
mwiegand
bc8edbcd50
feat(modals): click intercept + openModal + fetchAndShow
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>
2026-05-17 12:00:28 +02:00
mwiegand
8df130a607
feat(modals): persistent modal slot + router script stub in base.html
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>
2026-05-17 11:51:07 +02:00
mwiegand
60e79683fc
feat(modals): GET /overlays/<id>/files/edit route
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>
2026-05-17 11:43:18 +02:00
mwiegand
a26b4cc34e
feat(modals): editor template that extends base_layout
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>
2026-05-17 11:34:31 +02:00
mwiegand
82c3f041ce
feat(modals): layout context processor for HX-Modal header
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>
2026-05-17 11:27:25 +02:00
mwiegand
2942467cfd
feat(files-overlay): filename click opens editor, actions align next to row
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>
2026-05-17 10:40:54 +02:00
mwiegand
54842f71c6
fix(editor-v2): fix cm6 to rows-derived height, eliminate layout shift
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>
2026-05-17 10:27:28 +02:00
mwiegand
2f1a1ef284
Revert "fix(editor-v2): reserve editor slot to stop layout shift on mount"
This reverts commit b915f2e766.
2026-05-17 02:34:24 +02:00
mwiegand
b915f2e766
fix(editor-v2): reserve editor slot to stop layout shift on mount
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>
2026-05-17 02:29:43 +02:00
mwiegand
fd0d96b349
fix(editor-v2): eliminate first-paint flicker
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>
2026-05-17 02:25:52 +02:00
mwiegand
19bc0afaa9
test(editor-v2): Playwright e2e + Tab→acceptCompletion fix
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>
2026-05-17 02:15:51 +02:00
mwiegand
42bdc6ad98
feat(editor-v2): files-overlay reads/writes via window.__filesEditor
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>
2026-05-17 02:08:42 +02:00
mwiegand
59446bc105
feat(editor-v2): wire data-editor-language attrs into three textareas
Templates:
- blueprint_detail.html:52 — config textarea gets
  data-editor-language="srccfg".
- overlay_detail.html:25 — script textarea gets
  data-editor-language="bash".
- overlay_detail.html files-modal — content textarea gets
  data-editor-language="auto"; new <select data-editor-language-select>
  (auto / srccfg / bash / plain); filename input gets
  data-editor-filename.
- Both templates {% include "_editor_assets.html" %} before
  {% endblock %}.

Tests (TDD red-green):
- test_blueprint_get_includes_editor_markup pins srccfg + bundle + glue
  in blueprint detail GET.
- test_script_overlay_detail_carries_editor_markup pins bash + bundle
  + glue in script overlay GET.
- Files-modal markup verified end-to-end in Task 14 Playwright (its
  pytest fixtures are heavyweight; not worth duplicating for a static
  markup assertion).

Fast suite stays at 564 passed (no regressions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:06:58 +02:00
mwiegand
9ca0e789f4
test(editor-v2): pin form-POST round-trip for blueprint config
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>
2026-05-17 02:02:47 +02:00
mwiegand
b1a6290c8c
feat(editor-v2): _editor_assets.html Jinja partial
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>
2026-05-17 02:00:29 +02:00
mwiegand
e4f863415e
feat(editor-v2): editor.js glue (mount, submit-capture, files alias)
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>
2026-05-17 02:00:17 +02:00
mwiegand
921168722b
feat(editor-v2): tokens.css syntax vars + editor.css shell
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>
2026-05-17 01:59:41 +02:00
mwiegand
6af2e41fd8
feat(editor-v2): build script + first bundle output
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>
2026-05-17 01:58:46 +02:00
mwiegand
bfc8b82c00
feat(editor-v2): editor-entry façade wiring all extensions
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>
2026-05-17 01:57:23 +02:00
mwiegand
3440bbc131
feat(editor-v2): autocomplete completion source
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>
2026-05-17 01:56:45 +02:00
mwiegand
5289ae307f
feat(editor-v2): light + dark themes + syntax highlight style
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>
2026-05-17 01:56:26 +02:00
mwiegand
9226963516
feat(editor-v2): srccfg StreamLanguage mode
~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>
2026-05-17 01:55:59 +02:00
mwiegand
7497cf5416
feat(editor-v2): vocab generator + cvar_list-derived JSON
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>
2026-05-17 01:55:33 +02:00
mwiegand
ce20c1abff
scaffold(editor-v2): pin cm6 deps + editor-src skeleton
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 01:54:06 +02:00
mwiegand
f14d352657
revert(editor): roll back textarea code editor (re-architecture in flight)
The contenteditable + CodeJar + Prism approach (Tasks 1-12 + 4 smoke
fixes shipped this session) hit too many contenteditable edge cases to
ship:

- Copy collapses multi-line selections to one line (Selection.toString()
  doesn't reliably reconstruct newlines across Prism's tokenized <span>
  topology).
- Enter sometimes requires two presses + cursor color shifts (caret
  lands "between" sibling tokenized spans; first Enter shifts it into
  a real text node, second actually inserts).
- Cascade of earlier bugs already fixed (cursor jumped to start, then
  end; popup-accepted-quote duplicated; popup didn't accept at
  end-of-line) were all symptoms of the same root cause: manual Range
  API manipulation against tokenized contenteditable DOM is unreliable.

Exiting the sunk-cost path before more fixes accrue. The next attempt
will be a fresh brainstorming session weighing CodeMirror 6 (battle-
tested, accepts a one-time bundler step) vs textarea-overlay (real
<textarea> for editing, passive <pre> highlight, no contenteditable).

Kept (informs the next attempt):
- spec + plan documents in docs/superpowers/
- Playwright scaffolding (conftest + smoke test) + dev deps + e2e marker
- scripts/dev-server.py (independent of editor approach)
- AGENTS.md sandbox + Chromium Mach-port notes

Removed:
- editor JS (editor.js, srccfg-grammar.js)
- editor CSS (editor.css)
- vendored CodeJar + Prism + README
- srccfg vocab data
- editor partial (_editor_assets.html)
- template wiring (data-editor-language attributes, asset partial includes,
  files-editor language <select>)
- files-overlay.js editor bridge (setEditorContent helper, dropdown
  listener, filename-handler auto-redetect, dropdown reset)
- tokens.css syntax-color additions (dead without the editor)
- form-contract tests in test_blueprints.py + test_script_overlay_routes.py
- the editor-specific Playwright test (test_editor.py)
- create-blueprint modal trim that was tied to editor UX (Arguments +
  Config textareas restored)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:53:26 +02:00
mwiegand
338b7baff3
feat(blueprint): strip create-modal to name-only
The new-blueprint modal had Name + Arguments + Config textareas, but
the modal lives on blueprints.html (the list page), not on
blueprint_detail.html, so neither textarea was wired to the srccfg
editor — mixing themed-editor and raw-textarea UX in the same flow.
Keep just Name; arguments/config are edited on the detail page where
the editor lives. Add autofocus to the name field for keyboard flow.

Server contract unchanged: create_blueprint (blueprint_routes.py:80)
already defaults arguments/config to [] when absent from the form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:03:57 +02:00
mwiegand
bee0f07d2f
fix(editor): drop prism.css to unblock dark-mode rendering
Prism's stock theme has

    code[class*=language-] { color:#000; background:transparent; }

at specificity (0,1,1), which beats our .editor-code (0,1,0). Result:
the editor's background was transparent and base text was #000, leaving
black-on-dark text in dark mode (unreadable).

We override every Prism token class we use (.token.comment / .string /
.keyword / .number / .operator / .identifier) via theme-aware
--color-* tokens defined in both :root and the
@media (prefers-color-scheme: dark) block of tokens.css, so prism.css
contributes nothing of value. Drop the <link> from _editor_assets.html.
Flip the form-contract tests to assert prism.css is NOT in the body so
a future accidental re-add is caught.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:03:48 +02:00
mwiegand
9a773093a8
fix(editor): correct caret behavior in autocomplete accept + disable auto-close
Four smoke-discovered fixes in acceptCompletion / CodeJar options:

- Backward selection walk via Selection.modify replaces the buggy
  range.setStart(endContainer, endOffset - fragment.length). The old
  code assumed the fragment lived in endContainer; at end-of-line the
  caret often sits in a post-Prism-<span> text node, so the subtraction
  went negative → IndexSizeError → caught silently → popup dismissed
  with no insert.
- Save/restore caret around updateCode because codejar.js:469-474 does
  editor.textContent = code; highlight(editor) with no caret preservation,
  which dropped the caret to the start of the editor.
- Set the selection inside the inserted text node before save() so
  CodeJar's save() doesn't trip its anchorNode === editor special case
  at codejar.js:122-127, which collapses to end-of-all-text.
- addClosing: false on both CodeJar constructors so closing quotes
  don't duplicate — CodeJar's addClosing: true default inserts a paired
  closing character without skipping past an existing one, producing
  e.g. "rcon_password"" when you finish a string literal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:03:37 +02:00
mwiegand
5bec91ab17
perf(overlay): only ship editor assets to pages that mount an editor
Addresses Important #2 from the final code review.

The asset partial was previously included unconditionally for any
overlay detail page. Workshop overlays and read-only files-type
overlays (when the viewer isn't the owner or admin) have no
data-editor-language textarea, so the ~30 KB of Prism + CodeJar + JS
+ CSS shipped pointlessly. Gate the include on the two cases that
actually mount an editor: script-type overlays (bash editor) and
files-type overlays where the current user has edit rights (the
files-editor modal).

I-1 from the review (race window during "Loading…") was confirmed
moot — editorDialog.showModal() only fires after the fetch resolves
(files-overlay.js:409), so the dialog is invisible during the
fetch-and-placeholder window and the user can't type into it.

I-3 (Playwright coverage of the language dropdown override) is real
follow-up work that needs a new files-type overlay seed in the
live_server fixture. Deferred as a v2 ticket.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:25:44 +02:00
mwiegand
86fe564ff8
test(e2e): editor autocomplete end-to-end
Logs in as the seed user, navigates to the blueprint detail page,
types sv_che into the editor, asserts the autocomplete popup appears
with sv_cheats, accepts via Tab, and asserts the hidden textarea
(form field) now contains the inserted cvar.

This exercises the full chain end-to-end: editor mount on
DOMContentLoaded, srccfg-vocab.json fetch, popup positioning,
capture-phase keydown handling (Task 9 fix), Range-API completion
insertion, and textarea-mirroring on every input.
2026-05-16 22:06:22 +02:00
mwiegand
f030395a57
fix(e2e): force SESSION_COOKIE_SECURE=0 + document init_db duplication
Two follow-ups from the Task 11 code review.

Important — without SESSION_COOKIE_SECURE=0, Task 12's Playwright
login would silently fail. app.py:57 sets SESSION_COOKIE_SECURE = not
TESTING, so with our TESTING=False conftest the cookie is marked
Secure; the browser drops it over http://127.0.0.1 and the
session never establishes. The env-var override (app.py:53-55) is the
least invasive fix and preserves the SECRET_KEY guard.

Minor — the second init_db() looked redundant but is actually load-
bearing: create_app's init_db runs inside the app context (binds to
the in-app engine), while the seed work uses session_scope() outside
the app context (binds to an env-derived engine). The second
init_db() creates tables on THAT engine. Added a clarifying comment
so a future reader doesn't drop the line and silently break the seed.

Addresses Important #1 + Minor #1 from the Task 11 code review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 21:07:15 +02:00
mwiegand
f30b9a6b0c
test(e2e): scaffold Playwright + live-server fixture
Adds playwright + pytest-playwright to workspace dev deps, an e2e
pytest marker, and a live_server fixture that boots the Flask app on
an ephemeral port with a temp SQLite DB. addopts default to -m 'not
e2e' so the regular fast suite excludes them; explicit
`pytest -m e2e` runs them. Smoke test confirms the live server is
reachable.

Workspace root pyproject.toml is the right place for the dev deps and
pytest config — l4d2web/pyproject.toml is minimal and has neither.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:00:45 +02:00
mwiegand
8e8a3aeb3e
fix(files-editor): reset language dropdown on every modal open
Without this, a user who picked a manual override (e.g. "Bash") on
one open would see the stale selection on the next open while the
editor itself silently re-derived from the filename via
setEditorContent's setLanguage("auto") call. The displayed dropdown
would lie about the active language.

Additionally, the existing filename-input handler's
"if (languageSelect.value === 'auto') re-derive" check was effectively
disabled whenever the user had previously picked an override —
renaming the file wouldn't re-derive even though the active language
was already auto.

Addresses Important #1 from the Task 10 code review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:57:00 +02:00
mwiegand
3c882e020c
feat(files-editor): mount auto-language editor + dropdown override
The modal textarea opts in with data-editor-language=auto; the editor
derives the language from the filename extension on each modal open.
A dropdown lets the user override (srccfg / bash / plain). The
existing fetch-based /files/save path is unchanged — files-overlay.js
keeps reading textarea.value, which the editor mirrors.
2026-05-16 20:51:35 +02:00
mwiegand
10cf0da3d2
fix(editor): capture-phase keydown + popup leak + cache warmup
Addresses Critical #1 + Important #2/#3/#4 from the Task 9 code review.

CRITICAL — Tab/Enter were stolen by CodeJar before the popup handler
saw them. CodeJar registers its keydown listener during construction
(line ~159), so it ran first in bubble order: Tab handler
preventDefaulted and inserted 2 spaces, Enter handler preventDefaulted
+ stopPropagation'd (with leading indent), so the popup-accept either
ran on corrupted state or never fired at all. Fix: register the popup
listener with {capture: true} and call stopPropagation on the keys we
own — that way capture phase fires before CodeJar's bubble listener
and the key is fully consumed by the popup while it's visible. Normal
typing (popup hidden) early-returns without stopPropagation, so
CodeJar's tab-indent + enter-preserve-indent still work when there's
no autocomplete to accept.

IMPORTANT — destroy() leaked the popup <ul> into document.body. Each
mount/destroy cycle (e.g. modal close/reopen) left an orphan popup.
Fix: pop.remove() in destroy().

IMPORTANT — async refreshPopup could race in stale renders if the
first keystroke fired the vocab fetch and the second keystroke
captured a different ctx before the fetch resolved. Fix: warm the
cache with a fire-and-forget loadVocab(language) at mount, so the
first user keystroke hits cache. Eliminates the only realistic window
for the race.

IMPORTANT — acceptCompletion's Range.setStart could throw
IndexSizeError on pathological state (caret inside a tokenized span
where the fragment isn't fully upstream). Fix: try/catch the entire
DOM mutation block, log + dismiss on failure. Plus an inline comment
documenting the single-text-node invariant the current grammars hold.

Plan source updated for the capture-phase fix (most important for
future regeneration); the other fixes are smaller and only mirrored
into the actual code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:49:23 +02:00
mwiegand
3d3629f592
feat(editor): add identifier autocomplete popup
Vocab loaded lazily from /static/data/<lang>-vocab.json on first
mount, cached in memory. Popup appears when the word fragment before
the caret has >=2 word characters and matches the vocabulary. Prefix
matches rank ahead of substring matches; popup shows up to 8 with
scroll. Up/Down navigate, Tab/Enter accept, Esc dismisses.

acceptCompletion uses instance.jar (not the captured closure) so
runtime jar reassignment via setLanguage stays consistent.
2026-05-16 20:42:03 +02:00
mwiegand
e6fe701718
data(editor): seed L4D2 cvar/command vocabulary
Hand-curated set of high-traffic cvars and commands sourced from the
existing l4d2-server-cvar-reference.md and common SourceMod usage.
Regeneration procedure documented in the file header.

30 cvars + 8 commands.
2026-05-16 20:39:33 +02:00
mwiegand
482312c3d8
feat(overlay): mount bash editor on script overlay form
data-editor-language=bash opts the textarea in; the editor uses
Prism's stock bash grammar (no project-owned bash code).

Partial include sits outside all conditional blocks in the template
so the editor assets load for both script-type and files-type
overlays.
2026-05-16 20:37:28 +02:00
mwiegand
c6f10e632d
test(blueprint): also assert prism.css is referenced in editor assets
The plan template (and verbatim implementation) listed five of the six
editor asset URLs in the structural test — vendor/prism.css was
omitted. If a future change drops the Prism stylesheet from the
partial, syntax tokens lose their color rules silently and the test
still passes. Add the missing assertion and update the plan to match.

Addresses Minor #1 from the Task 6 code review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:35:44 +02:00
mwiegand
607970eb43
feat(blueprint): mount srccfg editor on the config textarea
The textarea is preserved as the form field; the editor renders a
contenteditable sibling and mirrors content back on every input. Form
POST contract is untouched (covered by new round-trip test).
2026-05-16 19:39:58 +02:00
mwiegand
b203a83f58
feat(editor): add Jinja partial for editor asset includes
Five script/link tags consolidated so call-site templates only need a
single {% include '_editor_assets.html' %} to enable the widget.
2026-05-16 19:36:53 +02:00
mwiegand
04f9a4d6a2
fix(editor): narrow findFilenameInput scope + dispatch input from setValue
Addresses two Minor follow-ups from the Task 4 code review:

- findFilenameInput previously included `body` in its closest() selector,
  meaning any "auto" textarea outside a modal would walk all the way up
  and pick up the files-editor modal's filename input from elsewhere in
  the document. Drop `body` so out-of-modal "auto" usage degrades
  cleanly to "plain".
- setValue now dispatches an `input` event on the textarea after
  writing, matching the onUpdate mirror. Task 10 wires the files-editor
  modal to call setValue when loading file content — without this fix,
  textarea-listening code (e.g. unsaved-changes indicators) wouldn't
  see programmatic loads. Now setValue and user typing produce the
  same observable side effects.

Plan source block updated to match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:35:47 +02:00
mwiegand
e29eaf3254
feat(editor): widget core — mount, sync, language switch
Mounts on <textarea data-editor-language>, hides the textarea, renders
content in a contenteditable sibling with Prism highlighting via
CodeJar. Mirrors content back to textarea.value on every input so form
POST and existing JS readers keep working unchanged. Exposes
setValue/setLanguage/getValue on textarea._codeEditor for callers.

Language switch uses tear-down-and-remount because CodeJar captures
its highlighter by closure at construction time and has no API to
swap it on a live instance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:29:27 +02:00
mwiegand
cdcb7e4853
style(editor): visible light-mode popup active state + plan sync
Addresses Important #1 + Minor #2/#3 from the Task 3 re-review:

- --color-bg-popover-active light value: #f3f4f6 → #e5e7eb. The prior
  value was within ~1.05:1 luminance of the white surface — keyboard
  navigation through the autocomplete list had no visible focus
  indicator in light mode. e5e7eb (Tailwind gray-200) clears that.
- Drop dead fallback hexes on the four guaranteed tokens
  (--color-string/-keyword/-number/-bg-popover-active). They never
  fired post-fix and only produced a dark-mode-only palette if
  tokens.css somehow failed to load — i.e. they were misleading.
- Plan source block (Task 3 Step 2) replaced with the post-fix CSS
  verbatim + a new Step 2b that documents the tokens.css additions
  alongside the editor.css template, so a fresh regeneration
  produces the same file.

Deferred: cross-cutting --font-mono token (Minor #4 — would touch 7+
sites outside Task 3's scope).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:27:07 +02:00
mwiegand
f9c8518212
style(editor): theme-aware syntax tokens + match textarea metrics
Addresses Important #1 + #2 from the Task 3 code review:

- Adds --color-string, --color-keyword, --color-number,
  --color-bg-popover-active to tokens.css in both the :root and dark
  blocks. GitHub-style palette tuned for legibility on each theme's
  surface.
- Updates .editor-code to use the same padding tokens, font-family
  stack, font-size, and line-height as the existing textarea rule so
  the contenteditable doesn't visibly jump when the widget mounts.
- Drops the caret-color override (browser default adapts to system
  theme — no token needed).

Plan source block updated to match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:22:27 +02:00
mwiegand
75a586a47b
style(editor): add stylesheet for editor shell + Prism tokens + popup
Defines .editor-shell, .editor-code, .editor-popup. Reuses tokens.css
variables where present so the editor matches the site palette.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 19:17:05 +02:00
mwiegand
db1a255223
fix(editor): drop dead -? from srccfg number regex
The `\b` word boundary anchor prevents the optional minus from ever
matching from positions where signed numbers naturally appear (` -1`,
`(-1`, `=-1` all word-boundary-from-non-word and the `-?` fires zero
chars). Negative numbers are tokenised via the operator class instead,
which is the consistent behaviour the grammar already exhibits.
Plan source block updated to match so a fresh regeneration produces the
same file.

Addresses Minor #1 from the Task 2 code review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:15:34 +02:00
mwiegand
7cfbedb929
feat(editor): add Prism grammar for Source-engine .cfg syntax
Five token classes (comment, string, keyword, number, identifier) plus
operators. Purely visual highlighting; no semantic validation of cvar
names or values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 19:11:56 +02:00
mwiegand
02e9edd4ed
vendor(editor): add CodeJar attribution header + source-form rationale
Brings codejar.js to parity with prism.js, which already carries a
self-documenting header. The README also now records *why* the
unminified source form was chosen: CSP rules out runtime sourcemap
loading from a CDN, so debuggability lives in the vendored bytes.
SHA256 column updated to match the new file content.

Addresses Minor #1 + #2 from the Task 1 code review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:10:39 +02:00
mwiegand
6ade91b870
vendor(editor): pin Prism v1.29.0 + CodeJar v4.0.0
Self-host the editor dependencies under /static/vendor/ since the strict
CSP forbids CDN loading. README records source URLs, versions, and
SHA256s for each file.
2026-05-16 19:04:26 +02:00
mwiegand
18113637e9
refactor(datetime): introduce UtcDateTime, remove naive-strip workarounds
Adds a UtcDateTime TypeDecorator (models.py) that enforces aware-UTC on
write and stamps tzinfo=UTC on read. Replaces 26 DateTime column
declarations. Removes 5 production sites that defensively stripped tzinfo
to match SQLite's lossy round-trip. auth.py now coerces legacy session
cookies upward (stamp UTC on parsed naive marker) instead of stripping
live aware markers downward.

The change is Python-side only: UtcDateTime.impl = DateTime, so DDL and
emitted SQL are unchanged. No Alembic migration needed.

Adds 2 unit tests in test_models.py pinning the decorator's contract
independently of the column declarations.

The three deliberately-naive test_timeago.py fixtures (lines 67, 73, 113)
remain naive on purpose -- they exercise _ensure_utc's normalize-up path
at the public filter boundary, which stays as belt-and-braces defense.

See docs/superpowers/specs/2026-05-16-tz-aware-datetime-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:59:29 +02:00
mwiegand
a5436deaf0
test(datetime): pin tz-aware contract for fixtures (red until UtcDateTime lands)
Drops .replace(tzinfo=None) from 8 fixture sites that mirrored the
production-side strip convention. Two of these (test_live_state_poller.py
test_new_player_opens_session_with_backfilled_join, test_models.py
test_user_has_password_changed_at_default) now fail with TypeError when
comparing aware in-memory values against naive DB reads -- that failure
is intentional and describes the contract commit 2 must satisfy:
DB-sourced datetimes return aware UTC.

The remaining 6 sites were already cosmetic (fixture-seed only, no
aware-vs-DB comparison) but are flipped here so future authors write
aware fixtures.

The three deliberately-naive sites in test_timeago.py (lines 67, 73,
113) are LEFT untouched -- they exercise _ensure_utc's normalize-up
path and are feature tests, not workarounds.

See docs/superpowers/specs/2026-05-16-tz-aware-datetime-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:55:48 +02:00
mwiegand
6cef55f900
fix(csp): allow workshop preview thumbnails from steamusercontent.com
Steam serves workshop preview images from images.steamusercontent.com,
which the previous img-src whitelist did not cover, so the browser
silently blocked every <img> in _overlay_item_table.html.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:22:30 +02:00
mwiegand
55b2abfdc9
refactor(server_routes): drop unused 'now' kwarg from _live_state render
After the timeago migration, the live-state template no longer reads
'now' — it computes relative labels through the filter, which derives
its own reference time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:15:14 +02:00
mwiegand
b6305f2aac
refactor(page_routes): pass datetime to templates for timeago filter
Drop the inline humanize_delta imports and string-precomputation; pass
the raw datetime as latest_job_at / latest_build_at and let the
template apply the timeago filter. One fewer code path computing
relative-time strings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:14:08 +02:00
mwiegand
99e477700a
refactor(templates): use timeago filter in _live_state.html
Replaces three bespoke (now - x).total_seconds() expressions with the
shared filter, unifying vocabulary (no more '0m ago' inside the first
minute) and adding the UTC tooltip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:12:26 +02:00
mwiegand
d9cee233ab
refactor(templates): use timeago filter for job timestamps
Preserves the existing '-' placeholder for nullable started_at /
finished_at columns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:11:56 +02:00
mwiegand
4f6d9bcca6
refactor(templates): use timeago filter for admin/blueprint timestamps
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:11:23 +02:00
mwiegand
263a9a9f27
feat(app): register timeago Jinja filter
Templates can now call {{ ts | timeago }} directly without route-side
precomputation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:10:59 +02:00
mwiegand
1926fe895c
feat(timeago): add format_time_html returning a <time> element
Wrap humanize_delta in an HTML <time> element with datetime= and
title= attributes carrying the precise UTC value, so hovering surfaces
the exact timestamp regardless of the relative label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:09:23 +02:00
mwiegand
237f26e5cb
feat(timeago): symmetric ladder with second precision and date fallback
Rewrite humanize_delta as a symmetric past/future ladder with
sub-minute precision. Replace the bare ISO date fallback after 7 days
with a day-month form (year suppressed when same as now). Refs spec
docs/superpowers/specs/2026-05-16-timeago-shared-display-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:08:43 +02:00
mwiegand
49992b3a26
refactor(repo): uv workspace + hatchling + layout restructure
Migrate from pip-install-e + setuptools to a uv workspace with a
committed uv.lock for deterministic deps. Switch both members to
hatchling, and move package sources into nested standard layout
(l4d2host/l4d2host/, l4d2web/l4d2web/) so builds work from a
read-only source tree — setuptools wrote egg-info to source under
the old layout, which broke uv sync on the root-owned /opt/left4me/src.

Local dev install: `pip install -e ./l4d2host -e ./l4d2web` -> `uv sync`.
.envrc switches from `layout python python3.13` to `use uv`. Python
pinned to 3.13 via .python-version.

l4d2web now declares its cross-dep on l4d2host explicitly via
[tool.uv.sources] (workspace = true). l4d2web/alembic.ini and
l4d2web/alembic/ stay at the project root (standard alembic layout).

Test fixes:
- tests/__init__.py added to both test dirs so pytest doesn't shadow
  l4d2host as a namespace package via outer-dir walk.
- 3 CWD-relative paths in tests (l4d2web/static/css/{tokens,layout}.css
  and js/sse.js) anchored to Path(__file__) so they survive layout
  changes.
- Two test_install.py tests now monkeypatch HOME to tmp_path so they
  stop silently mutating ~/.steam/sdk32 on every run.

628 tests pass under sandboxed `uv run pytest`.

Per docs/superpowers/plans/2026-05-15-uv-workspace-execution.md;
prereq for the ckn-bw bundle's uv-sync action (queued).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 22:04:29 +02:00
mwiegand
e28d4fad8c
l4d2web/csp: allow Steam avatar CDN in img-src
The live-state grid renders player avatars as <img src="https://avatars.steamstatic.com/...">,
but the CSP img-src directive was `'self' data:` — so the browser
silently blocked every avatar load, leaving placeholder circles in
place. The DB cache and Steam API path were both healthy; only the
browser-side load was blocked.

Use the wildcard *.steamstatic.com host-source rather than pinning a
single hostname: Steam rotates avatars across steamcdn-a.akamaihd.net,
avatars.akamai/cloudflare/fastly.steamstatic.com over time, and a
single-hostname allowlist would re-break on the next shuffle.

Test now pins img-src explicitly — the previous assertions only
checked default-src/frame-ancestors/form-action, so a regression of
this exact line would have silently passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:23:29 +02:00
mwiegand
8971b23617
refactor(sandbox): collapse l4d2-sandbox user into left4me
The hardening refactor that just landed closes the same-uid attack
surface (FS view, ptrace, /proc visibility, signals) for the web +
gameserver units via systemd directives plus system-wide
kernel.yama.ptrace_scope=2. Keeping the script-sandbox on a separate
uid was the inconsistent half-step — defense-in-depth only, with
build-time-idmap complexity attached. One principle wins: harden
once, share the uid.

scripts/libexec/left4me-script-sandbox: drop the idmap block (uid
lookups, STAGING setup, cleanup_staging trap, mount --bind
--map-users), switch User=/Group= to left4me, point BindPaths at
\$OVERLAY_DIR directly. Header comment updated to reflect
hardening-not-uid as the same-uid defense. nsenter self-wrap kept —
it's about mount-namespace escape, not uid.

Tests + comments + companion docs updated. Build-time-idmap and
overlay-idmap plans marked SUPERSEDED; user-uid-split spec revised
to "1 user is correct"; one-line update notes on the hardening
specs and the build-overlay-unit-design.

Companion ckn-bw commit removes the l4d2-sandbox user + group and
tightens /var/lib/left4me from 0711 → 0755 (the traverse-only mode
was specifically for the sandbox uid).
2026-05-15 15:50:57 +02:00
mwiegand
8f30dd7754
docs: correct stale bubblewrap references in v1 spec + live docstring
Janitorial item 6 in 2026-05-15-janitorial-cleanup.md. The v1 sandbox
design (2026-05-08-l4d2-script-overlays-design.md) was approved
2026-05-08 and superseded the same day by the v2 systemd-only design
(2026-05-08-l4d2-script-sandbox-v2-systemd.md). The current
left4me-script-sandbox helper uses systemd-run in service-unit mode;
no bwrap binary is invoked. The v1 spec still described bubblewrap as
the engine.

- v1 spec gets a top-of-file banner pointing at v2 as the supersede.
  Body preserved; the rest of the v1 design (overlay-type unification,
  resource caps, helper auth) is still valid — only the sandbox engine
  changed.
- l4d2web/services/overlay_builders.py: ScriptBuilder docstring
  "bubblewrap + systemd-run" → "hardened systemd-run transient
  service" (the as-built reality).
- scripts/tests/test_script_sandbox.py: stray "/bwrap" in a comment
  cleaned up. Negative regression assertions (`assert "bwrap" not in
  text`) intentionally retained as the guard against accidental
  re-introduction.
- Plan docs left untouched (historical action snapshots).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 12:12:31 +02:00
mwiegand
bbb2b983bc
harden(l4d2web): per-username login rate limit alongside per-IP
A 20-attempts-per-60s budget keyed by IP doesn't slow a distributed brute force that rotates source IPs. Add a parallel per-username bucket with the same threshold so a single account can't burn through more than 20 failed logins/min regardless of where they come from. Empty usernames aren't bucketed (would DoS the anonymous 401 path). Successful login clears both buckets.
2026-05-14 22:26:20 +02:00
mwiegand
0e2a78e065
secure(l4d2web): block non-admin writes on system overlays; last-admin guard on deactivate
_load_files_overlay docs already promised "owner or admin" for mutations, but the check only filtered by overlay.type — system overlays (user_id IS NULL) were writable by any logged-in user. Add the explicit 403 for non-admins; read-only routes remain open across all overlay types.

Mirror the delete-route last-admin guard on /admin/users/<id>/deactivate so a future auth-model change (service accounts bypassing require_admin, etc.) can't accidentally lock out the system.
2026-05-14 22:24:19 +02:00
mwiegand
74b7f61437
harden(l4d2web): default security response headers and generic error handlers
- after_request hook sets X-Content-Type-Options=nosniff, X-Frame-Options=DENY, Referrer-Policy=strict-origin-when-cross-origin, and a strict CSP (default-src 'self', script-src self+nonce, frame-ancestors 'none', form-action 'self'); HSTS added on secure non-test responses
- per-request CSP nonce minted in g.csp_nonce; servers.html's inline showModal script picks it up
- 404 and 500 handlers return short plain-text responses so a misbehaving deployment can't leak tracebacks via Werkzeug's debug page
2026-05-14 22:21:36 +02:00
mwiegand
2902c9cc82
harden(l4d2web): auth/session — clear on login+logout, constant-time CSRF, role-change invalidation
- login_user clears any pre-login session state before stamping user_id/pw_changed_at/admin so a fixated cookie value cannot smuggle data past the login boundary
- logout_user now session.clear()s instead of only popping user_id, removing leftover pw_changed_at/admin markers
- CSRF token comparison uses hmac.compare_digest
- load_current_user rejects sessions where the stamped admin flag no longer matches the user row, preventing a demoted admin from retaining elevated access until next password change (backward-compatible: sessions issued pre-upgrade lack the marker and pass through until next login)
2026-05-14 22:18:46 +02:00
mwiegand
66d14feca5
refactor(l4d2-web): harden console-history.js against HTMX version drift and races
- pendingCommand captured in htmx:beforeRequest (not requestConfig).
- ensureLoaded shares a single inflight Promise across concurrent calls.
- Document why synthetic null-id entries are safe in the cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:42:05 +02:00
mwiegand
6f49efd44a
feat(l4d2-web): console panel UI on server detail page
- _console_line.html: command + reply, error variant, "(no reply)" placeholder.
- server_detail.html: console section between Live State and Files, replays
  last 50 history rows server-side; HTMX form appends new lines via hx-swap.
- console-history.js: ArrowUp/Down recall against /console/history JSON;
  scroll-to-bottom on load and after each new line.
- CSS: fixed-height scrolling transcript, terminal-ish styling, spinner via
  HTMX in-flight class.
- test_console_routes.py: update 4 assertions from legacy [ERROR] literal
  to console-error CSS class (matches new semantic markup).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:39:21 +02:00
mwiegand
ecc4aa28c6
refactor(l4d2-web): tighten console route limit test and dedupe is_error
- ?limit clamp test now actually verifies the clamp instead of just
  passing through 5 rows.
- Single is_error assignment per branch, single db.add path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:35:22 +02:00
mwiegand
553b280e40
feat(l4d2-web): backend for RCON console with persisted transcript
- POST /servers/<id>/console runs a command via rcon.execute_command and
  persists every outcome (success / empty / error) to command_history.
- GET /servers/<id>/console/history returns paginated newest-first JSON
  for client-side up-arrow recall.
- server_detail() now passes the last 50 history rows as console_history
  for server-side replay on page load.
- 404 on ownership mismatch — no admin override.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:32:13 +02:00
mwiegand
c4dffd471b
feat(l4d2-web): add command_history table for RCON console transcript
A row per RCON command execution: (user, server, command, reply, is_error,
created_at). Composite index on (user_id, server_id, id) supports the only
query shape — "latest N for this user+server", id DESC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:26:56 +02:00
mwiegand
9ef9ffdbde
chore(l4d2-web): clarify rcon req_id constants and helper docstring
Add comment noting _EXEC_REQ_ID/_MARKER_REQ_ID are arbitrary client-chosen
values unrelated to SERVERDATA_* packet-type constants. Update _connect_and_auth
docstring to accurately reflect that OSError/socket.timeout propagate raw from
post-connect send/recv, while only connect failure is wrapped in RconError.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 21:24:41 +02:00
mwiegand
085fd714a5
feat(l4d2-web): add execute_command to rcon service with full test coverage
Extracts _connect_and_auth helper from query_status, adds execute_command
using the trailing-marker pattern for multi-packet reassembly, and covers
all paths (happy path, multi-packet, empty reply, auth failure, timeout,
input validation, marker drain) with 10 new tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:21:41 +02:00
mwiegand
6cc1736f17
feat(l4d2-web): add hostname edit form to server detail page 2026-05-13 15:42:46 +02:00
mwiegand
963851c0e1
feat(l4d2-web): emit hostname in spec config with ephemeral fallback 2026-05-13 15:31:12 +02:00
mwiegand
69d93dda4f
feat(l4d2-web): accept hostname on server update, default empty on create 2026-05-13 14:29:53 +02:00
mwiegand
0a7f48f174
feat(l4d2-web): add hostname column to Server model 2026-05-13 14:26:14 +02:00
mwiegand
fe43f67b51
feat: include password-reveal.js in base template 2026-05-13 11:37:47 +02:00
mwiegand
ab83f5fd2b
feat: add RCON password row to server detail page 2026-05-13 11:37:28 +02:00
mwiegand
d9aa6bd395
feat: add password reveal toggle JS 2026-05-13 11:36:40 +02:00