Commit graph

141 commits

Author SHA1 Message Date
mwiegand
b43bb9e0fa
test(files): add server-detail e2e fixture
Adds server_with_files fixture: seeds alice + a Blueprint + a Server
row pinned to port 27015, then pre-creates
LEFT4ME_ROOT/runtime/<server_id>/merged/ with a single seed file.
The /servers/<id> page lists files from that merged directory (the
kernel-overlayfs view of a running server), which never exists on
dev/test boxes — without the pre-mkdir + seed, the page renders
the empty-state branch instead of file rows.

Server.port is a global UNIQUE constraint on the model, but every
e2e test gets its own SQLite DB, so a fixed L4D2-default port is
fine.

Sets the stage for the Tier 3 hover-download test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:20:20 +02:00
mwiegand
e89dd25cdd
test(files): cover internal drag row to folder move
Drags server.cfg from the overlay root onto the cfg/ folder row,
asserts both that the file actually moved on disk AND that the tree
reflects the move after the debounced HTMX refresh of both parents.

Exercises the internal-drag path in uploads.js:332-366: the
custom-MIME setData/getData contract, the POST /files/move call,
and the dual scheduleRefresh(parentOf(src)) + scheduleRefresh(dst)
on success. Playwright's locator.drag_to() synthesizes setData
correctly — it relies on dataTransfer.getData(), NOT
webkitGetAsEntry which Playwright cannot fake.

Pitfalls handled inline: scoped the source/target locators to
li.file-tree-row-{file,dir} because action buttons inside the row
duplicate data-target-path + data-row-kind and would trip strict
mode. Used to_have_count instead of to_be_visible because cfg's
children div is collapsed (hidden) by default — the moved row is
in the DOM but not visually rendered until cfg is expanded.

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md
(Tier 2 case C).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:18:57 +02:00
mwiegand
a6be29c6d2
test(files): cover Escape closes editor with no stale state
Opens server.cfg, drives the CM6 controller to a dirty buffer,
presses Escape to close without saving, then reopens the same file
and asserts the editor shows the original disk content — not the
discarded buffer.

Pins two invariants: native <dialog>'s cancel→close path stays
intact (no JS shortcut around Escape), and the reopened editor
fetches a FRESH fragment via htmx.ajax with CM6 re-mounting on
the new textarea. A regression that cached buffer state to "feel
snappy" would fail this test loudly.

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md
(Tier 2 case B).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:17:21 +02:00
mwiegand
b222fdc918
test(files): cover share-URL deep link reopens editor
Navigates directly to /overlays/<id>?modal=<urlencoded edit path>
and asserts the routed editor auto-opens on the right file.

This is the central guarantee of the URL-addressable modals spec —
copy the URL, share it, and the recipient lands on the same modal
state. A regression in modals.js's DOMContentLoaded bootstrap (the
URLSearchParams.get("modal") → fetchAndShowRouted chain) would
silently dead-link every shared URL; this test fails loudly instead.

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md
(Tier 2, highest-value case).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:16:51 +02:00
mwiegand
81ba4ac83a
test(files): cover filename-rename on save
Opens server.cfg, changes the filename input to server-renamed.cfg,
clicks Save, and asserts: the routed modal closes, the old name is
gone from disk, the new name carries the original content, and the
tree row swaps over. Pins the rename-on-save branch in
routedSaveClicked (the non-is_new path that diffs originalLeaf vs
editedFilename and emits `new_path`).

Deliberately omits __filesEditor.setContent — rename should preserve
the textarea-seeded content. A regression that wrote an empty body
fails on the content-equality assertion.

Tier 1 complete: 7 tests + fixture extension. Full suite runs in
<10s on warm Chromium.

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:46:40 +02:00
mwiegand
3ea57b2bdb
test(files): cover new-folder + delete cycle
Creates a folder via the inline new-folder dialog's Enter-keydown
submit path, then deletes it via the inline delete-confirm dialog.
Each step asserts disk + tree state. The Enter path is the
direct-bound listener (not delegated), so it's the most likely to
break under future refactors — pinning it here surfaces such
regressions immediately.

Row-action buttons (`✕`) live inside `<li draggable="true">` — the
draggable ancestor confuses Playwright's hit-test even though real
browsers click through fine. The click uses force=True to skip the
hit-test (documented inline).

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:44:58 +02:00
mwiegand
6b0fbb75bf
test(files): cover binary replace via browse
Opens icon.png, attaches new bytes via Playwright's file chooser
(intercepting the click → hidden-input.click() → OS picker chain),
clicks Replace, and asserts the new bytes land on disk under the
unchanged filename. Covers the multipart /files/replace endpoint and
the click → setRoutedReplacement → save-enabled UI wiring.

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:42:13 +02:00
mwiegand
c1ea5eb11e
test(files): cover binary editor UI
Opens icon.png and asserts the routed editor renders the binary
branch of overlay_file_editor.html: replace-zone present, save
button labelled "Replace" and disabled on open, download link
pointing back at /files/download.

The same /files/edit route serves both text and binary modes — the
server picks the template branch from is_editable() + a magic-byte
check. Without this test, a regression that flipped a binary file
into the text branch would mean rendering raw PNG bytes inside an
editable textarea (and a misleadingly working save button).

Also asserts no textarea[data-rel-path] is in the DOM, so a future
regression that left both branches enabled fails loudly.

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:41:37 +02:00
mwiegand
aad8356613
test(files): cover 409 askConflict keep-both path
Tries to create `cfg`, which collides with the seeded directory of the
same name. /files/save returns 409 "destination is not a file";
routedSaveClicked routes that through fo.askConflict, which opens the
inline #files-conflict-modal on top of the still-open routed editor.
Clicking keep-both triggers a second POST with the suffixed path
(`cfg (1)`), the routed modal closes, and the new row materialises in
the tree.

This is the F4 path from 8dc14f0 ("wire askConflict into the routed
new-file 409 path"). Before that commit, the routed code branch fell
through to a generic alert(). With this test in place, a missing
call site fails loudly instead of silently.

Pins three invariants:
  * The conflict dialog is INLINE, not routed — it appears without a
    URL change (the decision tree in AGENTS.md "Modals: inline vs
    routed" hinges on this).
  * .files-conflict-path echoes the original colliding path, not the
    computed suffix — the suffix is internal, the user sees the
    collision.
  * withCollisionSuffix("cfg") → "cfg (1)" (no dot after the last
    slash → trailing-suffix branch in uploads.js).

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:40:56 +02:00
mwiegand
d92f71f691
test(files): cover routed new-file flow
Clicks `+ new file` at the overlay root, fills the routed editor's
filename + CM6 content, and clicks Create. Asserts the modal closes,
the file lands on disk, AND the new row appears in the live tree
after the debounced HTMX refresh — the last assertion catches the
class of bug where /files/save persists but
scheduleRefresh(parentOf(fullPath)) never lands a fresh listing.

The new-file modal reuses overlay_file_editor.html with is_new=True;
this test exercises the branch in routedSaveClicked that composes
fullPath from filename + data-at-folder, distinct from the
rename-on-save path the edit-mode test covers.

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:39:51 +02:00
mwiegand
3cafdba2cc
test(files): cover text-file edit round trip
Opens server.cfg through the file-row name button, drives the CM6
controller via window.__filesEditor.setContent, clicks save, and
asserts both that the routed modal closes and that the new bytes
landed on disk under overlay_root. Guards against four classes of
regression at once: the /files/edit fragment delivering the wrong
data-rel-path, editor.js's htmx:afterSwap re-init failing to wire
__filesEditor, routedSaveClicked stopping short of closeRouted(),
and the /files/save endpoint failing to persist.

Adds a `_wait_for_routed_editor` helper centered on `.cm-content`
inside `#files-editor-fragment` — the textarea itself is
display:none after CM6 mounts, so to_be_visible on the textarea
would always fail; the cm-content surface is the real "editor is
ready" signal.

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:39:03 +02:00
mwiegand
911bbf9103
test(files): add files-overlay e2e fixture
Extracts the shared boot/serve plumbing out of live_server into
_boot_app + _serve helpers, then adds files_overlay_server: a
function-scoped fixture that monkey-patches LEFT4ME_ROOT to tmp_path
BEFORE create_app(), seeds a files-type Overlay owned by alice, and
populates the overlay root with one editable text file, one binary
file, and one nested folder. Sets up the surface area the Tier-1
files-overlay e2e tests need without duplicating the live_server
boilerplate.

Also exposes a top-level `login(page, base_url, ...)` helper so
future test modules can share it instead of re-pasting the form-POST
flow.

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:35:43 +02:00
mwiegand
5f82950d7c
feat(files): delete /files/content endpoint + extract _apply_optional_rename
Step 12/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.
End of Phase C — end of the rewrite plan.

Two cleanups in one commit:

1. Delete GET /overlays/<id>/files/content.

The legacy openEditorForFile in files-overlay.js was its only caller,
and Step 9 deleted that code path. grep confirms no remaining live
callers (the matches in .claude/worktrees/* are other in-flight
branches; matches in docs/ are plan/spec text describing the route's
history). Removed:

  * The @bp.get route function (was already a thin wrapper around
    _load_file_for_editing from Step 11)
  * The endpoint's mention in the module docstring
  * test_content_returns_text
  * test_content_returns_415_for_binary
  * test_content_404_for_non_files_overlay
  * The /files/content entry in the batched "non-files-overlay 404s
    everywhere" test

The _load_file_for_editing helper from Step 11 becomes single-caller
(only the edit route uses it now). Kept because the function name
gives the prelude a useful named concept and inlining would add ~17
lines of low-density logic into overlay_file_edit_page.

2. Extract _apply_optional_rename.

overlay_file_save and overlay_file_replace had near-identical rename
branches: safe_resolve_for_move → 422-on-traversal, 409-if-dst-exists,
mkdir-parents, os.rename → echo_path = new_path. Extracted into
_apply_optional_rename(overlay, path, new_path) → (write_target,
echo_path) | Response.

The helper handles both cases:
  * Rename: atomic rename, returns (dst, new_path)
  * No rename: safe_resolve_for_write, mkdir parents, returns
    (write_target, path)

Save's "destination is not a file" 409 (creation branch) stays inline
in overlay_file_save — it's save-specific behavior that doesn't apply
to /replace (which assumes a file exists or creates one).

Subtle behavior change in /save: the prior code called
safe_resolve_for_write(new_path or path) upfront and then potentially
overrode write_target via safe_resolve_for_move. The new code only
calls one validator per branch. Confirmed equivalent: per
overlay_files.py:36-58 (safe_resolve_for_write) vs. lines 76-106
(safe_resolve_for_move), the dst-side checks are identical (root
escape, symlink refuse, parent-is-dir) and safe_resolve_for_move adds
strictly more (src must exist, cycle check for directory moves). pytest
covers the save/replace paths and stays green.

pytest: 580 → 577 passed, 1 skipped, 3 deselected. The -3 is the 3
deleted /files/content tests.

files_routes.py: ended at 735 lines. The plan estimated ~450 — the
delta is the new /files/new route (~43 lines, Step 5), the binary
template branch (Step 7), the _load_file_for_editing helper (Step 11),
and module-header expansions. The structural goal (no dead routes,
shared helpers across paths) is met.

End-of-plan summary:
  * 1091-line files-overlay.js → 4 focused modules totaling 1191 lines
    (core.js 247, editor.js 309, dialogs.js 212, uploads.js 423)
  * Editor flows (text edit, binary replace, create new) all run
    through URL-addressable modals (?modal= deep-linkable)
  * Legacy <dialog id="files-editor-modal"> deleted
  * /files/content deleted (dead endpoint)
  * Shared helpers _load_file_for_editing + _apply_optional_rename
  * pytest stayed green at every step
  * Chromium-verified every step

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:29:55 +02:00
mwiegand
10f93b863b
feat(files): delete legacy editor dialog + gut editor.js legacy paths
Step 9/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.

The inline <dialog id="files-editor-modal"> block in overlay_detail.html
is gone. All editor flows (text edit, binary replace, create new) now
exclusively use the URL-addressable modal swapped into #modal-content.

editor.js is now single-purpose (URL-addressable only). Removed:
  * editorDialog reference and the editorEls DOM-ref struct
  * The legacy editor state object
  * CM6 bridge wrappers + UI helpers (getEditorValue/setEditorValue/
    setEditorTitle/updateByteCount/updateRenameHint/updateSaveEnabled/
    setQueuedReplacement) — they only ever drove the legacy dialog
  * withCollisionSuffix (uploads.js still has its copy for the
    upload-conflict path; editor.js no longer needs it since the
    URL-addressable conflict path is "alert + keep modal open" rather
    than overwrite/keep-both)
  * openEditorTextNew and openEditorForFile — both functions were
    already unreachable from a user action after Steps 6/8
  * inLegacyEditor predicate
  * Direct-bound listeners on editorEls.filename / contentBox /
    editorDialog (input, keydown for Ctrl+S, close)
  * Legacy branches in every delegated handler (dragover, dragleave,
    drop, change, click)
  * legacySaveClicked and legacyDeleteClicked

What stays:
  * Routed state (routedReplacement, isRoutedBinaryMode,
    setRoutedReplacement, updateRoutedBinarySaveEnabled)
  * Delegated dragover/dragleave/drop/change/click handlers — now
    single-path each, no legacy/routed branching
  * Filename input delegated listener for routed binary mode (so
    rename-only Replace stays reachable)
  * modal-container close listener that clears routedReplacement
  * routedSaveClicked (text edit + is_new), routedReplaceClicked
    (binary, with rename-or-replace fork), routedDeleteClicked
  * "new-file" and "edit" registered handlers (the "edit" handler is
    no longer split editable/binary — the server picks the template
    branch)

routedDeleteClicked gained one capability that was missing in Step 8:
it now reads rel-path from either the textarea (text mode) OR the
.files-editor-binary panel, so deletion works for binary files in the
URL-addressable modal too (previously routed-mode binary delete fell
through to legacy, which is now gone).

Test added: test_overlay_detail_no_longer_renders_legacy_editor_dialog
asserts the legacy dialog markup is absent from /overlays/<id> while
the other inline dialogs are still present (Step 3 didn't move them).

Numbers:
  editor.js: 660 → 309 lines (-351). Plan estimated ~200; actual is
  ~50% larger due to module-header comments + the rename-or-replace
  fork in routedReplaceClicked. Pure-routing-only and single-mode
  per click, which was the structural goal.

  Total across files-overlay/: 1432 → 1191 lines.

pytest: 579 → 580 passed, 1 skipped, 3 deselected.

Verified live on /overlays/2 in Chromium:
  * id="files-editor-modal" not in DOM; new-folder/delete/conflict
    dialogs still present
  * "+ new file" → routed modal, Create button
  * Click other.cfg (editable) → routed modal, Save button, content
    pre-filled
  * Click test.png (binary) → routed modal, Replace button, initially
    disabled
  * No console errors

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:20:27 +02:00
mwiegand
294b5b8489
feat(files): add binary-file support to edit route + template
Step 7/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.

overlay_file_edit_page now renders the binary-replace UI instead of
returning 415 for non-editable files. Two cases bridge to the same
binary template: the up-front is_editable() check and the late-binding
UnicodeDecodeError on read (8-KiB sniff said yes but the tail had
non-UTF-8 bytes). Both paths route through a small _render_binary_editor
helper that fills in:
  * is_binary=True
  * content="" (no inline editor)
  * byte_count=target.stat().st_size
  * mime_type from mimetypes.guess_type, falling back to
    application/octet-stream

Template (overlay_file_editor.html) gains an is_binary branch in the
modal body: hides .files-editor-text (CM6 textarea + language
dropdown), renders .files-editor-binary instead with file-info note,
replace-zone (drop area + browse button + hidden file input), and
the per-state idle/queued labels. The Save button reads "Replace"
in binary mode and starts disabled — Step 8 enables it via JS when
a replacement file is queued.

Delete and Download stay visible in binary mode (binary files can be
deleted and downloaded the same way text files can).

The /files/new route gets is_binary=False + mime_type="" passed
explicitly so the template's binary branch never fires there.

Server-side only — no JS changes in Step 7. Step 8 wires up the
binary-replace handlers (replace-zone drag, browse/clear clicks,
Replace button → POST /files/replace).

Tests:
  * Removed test_edit_route_415s_for_non_editable_file — the route
    no longer returns 415 for non-editable files; the binary template
    is the new contract.
  * Added test_edit_route_renders_binary_template_for_non_editable
    (asserts 200 + is_binary markup + Save button label + disabled
    state + .files-editor-text absent + byte count rendered).
  * Added test_binary_template_has_replace_zone (asserts the replace-
    zone markup: drop zone, idle/queued labels, browse button, hidden
    file input, and that Download stays visible).

pytest: 578 → 579 passed, 1 skipped, 3 deselected. The pre-existing
"still 404 / still 400" cases the plan asks for are covered by the
existing test_edit_route_404s_for_missing_file and
test_edit_route_400s_for_path_traversal tests — left alone rather
than duplicated.

Verified live: GET /overlays/2/files/edit?path=test.png (an actual
binary file in the demo overlay) returns the binary template; DOM
inspection confirms .files-editor-binary present with data-rel-path,
"Replace" save button starts disabled, Download href points at the
file's download endpoint, MIME type and byte count match, .files-
editor-text is absent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:11:17 +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
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
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
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
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
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
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
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
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
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
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
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
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
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
096d18ac64
feat(live-state): use Steam avatarfull (184x184), downscale in CSS
64x64 avatarmedium looked soft on high-DPI screens. Switching the
GetPlayerSummaries field to avatarfull (184x184) and constraining
display size to 64px via .live-state .avatar gives sharp rendering on
retina/4k panels at the cost of a slightly larger CDN fetch (still
hot-linked, so no proxying cost).

Also adds the previously-missing CSS for the live-state player grid:
avatar+name+meta arranged in a tight 2-column grid per card, link
spans the avatar+name so the meta stays non-interactive.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:17:51 +02:00
mwiegand
6cbe7dc9f2
feat(live-state): link player cards to their Steam profile
Wraps avatar + persona name in an a[href=steamcommunity.com/profiles/<id>]
in both the Current and Recent blocks. Steam auto-redirects to the user's
vanity URL on follow, so we don't need to store profileurl separately.

target=_blank + rel=noopener noreferrer to keep the dashboard page in
place when a link is followed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:51:50 +02:00