Three usability fixes for the inspection strip on server_detail:
1. Pin transcripts/logs to bottom on tab activation.
2. Cap inline Console to 20 entries; modal keeps 50.
3. Pin to bottom after a console-line is appended via HTMX.
Approach B: single data-autoscroll opt-in attribute + one helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related fixes to the overlay file manager, found in the same session.
1. Nested-file save was silently moving the file (fix).
The Filename input is pre-filled with the full relative path
(intentional: phone-friendly move-via-edit when drag-and-drop
isn't an option). The save handler compared it against the
basename only, so every save of a file in a subfolder built
newPath = parent + "/" + editedFilename and re-routed the
file into a doubly-nested path (e.g. cfg/tick60.cfg ->
cfg/cfg/tick60.cfg). All three sites in editor.js now compare
against relPath. Two e2e tests pin both directions: save-without-
edit leaves the file untouched, edit-the-path performs the
intended move.
2. Recursive directory delete + visible 409 errors (feature).
GET /files/delete_preview enumerates what a recursive delete
would remove (files + dirs + symlinks, capped at 500 entries,
followlinks=False). POST /files/delete accepts an optional
recursive=1 form param that uses shutil.rmtree (default still
refuses non-empty dirs, preserving the historical safety
guard). The delete confirm modal now opens an inline preview
for non-empty folders, with a scrollable list and a count
summary. The error handler falls back to r.rawText so the
server's text bodies (like "directory is not empty") finally
surface to the user instead of "HTTP 409".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reversed the wrong conclusion in 46bba0d. The vanilla L4D2 logaddress
UDP path is NOT broken — proved by retesting with destination
172.30.0.5:28000 (wireguard IP) which yielded 8 properly framed HL
Log Standard packets in 12s including real game events.
Root cause: the Source engine silently drops logaddress destinations
in 127.0.0.0/8. Registration succeeds and the cvar API reports
"logging to: udp" but sendto is never called for loopback. Every
other L4D2 stats deployment (multiple production HLstatsX:CE
instances) puts the collector on a separate host or interface IP
and never hits this.
Defaults: LOG_LISTENER_BIND=0.0.0.0:28000 (accept on any interface);
LOG_LISTENER_ADDR="" (production must set via web.env to the host's
non-loopback IP). Empty default = safe no-op for dev. The kernel's
same-host routing optimization keeps the traffic on lo internally
but the packet's destination IP must not literally be in 127/8.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Investigation 2026-05-20: deployed listener captures nothing in
production. Diagnostics (strace + tcpdump) prove srcds makes ZERO
sendto calls toward registered logaddress destinations even though
the cvar API reports "logging to udp" and logaddress_list shows
the entry. File and console sinks work fine; the UDP path is
silently stubbed at the engine level (same family as broken L4D2
SourceTV). Listener and cfg injection retained for a future
SourceMod bridge that uses LogMessage() — that path does reach
UDP destinations on other Source 1 games.
Also drops mp_logdetail 3 (CS-only; L4D2 prints "Unknown command").
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every managed server now auto-injects log on / mp_logdetail 3 / logaddress_add
into its generated server.cfg, streaming HL Log Standard events to a UDP
listener bundled with l4d2web. The listener is deliberately capture-only —
raw packets land in flat files per source address — so we can observe what
L4D2 actually emits on our servers before committing to a schema or event
vocabulary. Match/round/event model is a Phase 2 plan informed by that data.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Moves the build-status badge (e.g. "ok" / "never built") into the
workshop section's .table-actions row, paired with the Refresh from
Steam button via a new .table-actions-end flex grouping. Drops the
now-redundant "Add items" field-label (aria-label preserves a11y).
The _overlay_build_status.html partial gains an omit_badge flag so
the workshop block can render the badge inline without it duplicating
inside the partial below.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two polish fixes caught in browser smoke after fa394c1:
1. Refresh from Steam is now class="button-secondary" — the brainstorm
consensus was "normal-styled button" so it doesn't compete with the
Add CTA, but left4me's default <button> is primary-blue, so the
class is required to opt out.
2. Global button:disabled rule added: opacity 0.5 + cursor not-allowed.
Was missing entirely, so the disabled attribute on Refresh-when-empty
produced no visible change. Generic, helps any future <button disabled>
too.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The global input rule (components.css:53-61) applies padding to ALL
inputs, including type=radio and type=checkbox. That inflated the new
.radio-row and .switch-row controls from 16x16px boxes into 40x32px
rectangles, which border-radius then rendered as ovals.
Adds padding: 0 + box-sizing: border-box to both control rules, and
border: 0 to the switch (the global border rule was squaring off its
pill shape). Caught in browser smoke after fa394c1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops the input-mode radio; the single textarea now accepts any mix of
items and collection URLs (backend autodetect landed in 5c56f18).
Refresh button moves below the items table into a .table-actions row
that also shows an item count + total size summary. Adds .workshop-input
mono font rule and a _humanize_bytes helper alongside the overlay_detail
view.
Plan deviation: PageAssertions has no not_to_contain_text method, so
the e2e test scopes those checks to a body locator instead. Caught in
review.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reorders fields to Name → Type → System-wide. Drops the legacy fieldset
border and the now-stale "path is generated automatically" hint. Type
radios use the new .radio-row vocabulary with always-visible
descriptions; the admin-only system-wide checkbox becomes a .switch-row
toggle. Form field names are unchanged, so the overlay-creation handler
is untouched.
Plan deviation: live_server e2e fixture now also sets LEFT4ME_ROOT.
This is required because the new test creates an overlay end-to-end via
the UI, and create_overlay_directory() writes under $LEFT4ME_ROOT,
which defaults to /var/lib/left4me (unwritable on dev machines).
The two existing live_server consumers (test_editor, test_smoke) only
visit /blueprints/<id> routes that don't touch the filesystem, so this
change is safe for them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reusable form-control primitives used by the upcoming overlay create-modal
and workshop-section redesigns. Custom-styled radios and a switch built
from native inputs (no JS), so accessibility and form behavior come for
free. Tokens unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
add_items now always calls expand_collections after parsing input, so a
single textarea accepts any mix of item IDs/URLs and collection IDs/URLs
without a mode toggle. The legacy "items vs collection" branching in the
handler is gone. Existing tests strip the now-ignored input_mode field;
two new tests cover the autodetect (collection-only) and mixed-paste
paths.
Plan deviation: rather than baking the expand_collections passthrough
into the _patch_steam helper (the plan's suggestion), uses a module-
autouse fixture _stub_expand_collections to stub it to identity by
default. The autouse approach handles the patch-shadowing problem the
helper version would have introduced for the new autodetect tests (which
need to assert specific args on a per-test expand_collections patch).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds expand_collections(ids) to steam_workshop: one GetCollectionDetails
POST covers a mixed batch of item and collection IDs, returning a flat
deduplicated list of item IDs in input order. Foundation for the upcoming
items-vs-collection autodetect in the workshop add_items handler.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Specifies the create-overlay modal redesign (field reorder, custom
radio-list, switch instead of checkbox, drop legacy path hint) and the
workshop-items section restructure (drop input-mode radio in favor of
autodetected items-vs-collections via batched GetCollectionDetails).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Throw away the historical naming. New vocabulary chosen for clarity and
agentic-dev predictability: parts use hyphenated child classes
(.card-header), variant modifiers chain on the parent (.button.primary),
state stays on ARIA attributes. Variants compose via Tier-3
component-scoped tokens (--button-bg etc.) — .button.danger.outline is a
real outlined-danger button with no combination rule.
Adds toast, spinner, heading, app-header as first-class components.
Renames panel→card, modal→dialog, badge→tag; collapses state-* into tag
variants via ui.lifecycle_tag. Adds an explicit template-rewrite phase
in the migration plan, since every template's class attributes change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ten tasks aligned with the spec's seven-commit migration:
foundation → elements/layout → core components → composites →
macros → widget relocation → utilities → styleguide → AGENTS.md
→ cleanup. Token migration table for old→new names. Pytest unit
tests for the field a11y macro and the /styleguide route.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the current ~1.4k LOC stylesheet with a tiered design system:
@layer-ordered cascade, two-tier tokens, budgeted component classes,
five high-leverage macros, in-app style guide, and the "system is closed"
workflow rule (every page element comes from the catalog).
Validated by a throwaway /spike comparing Pico v2, Simple.css, and the
pure-custom design; pure-custom won on the code-feel criterion the user
weighted highest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both current cards and recent chips now lay out as a 2-column grid:
larger avatar (36px current / 28px recent) on the left occupying both
rows, name on row 1 column 2, meta on row 2 column 2. Removes the
inline "· " prefix from recent-chip meta — it was a separator for the
old single-line layout and reads as visual noise when stacked.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switches .tab-pane from max-height: 18rem to height: 18rem. The console
pane was already always 288px tall because its flex layout reserves
space for the pinned input; the log and files panes were only as tall
as their content, so the inspection strip shrank when log scrollback
was short. Now every tab reports the same 288px height — strip height
no longer jumps when you switch tabs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumps .tab-pane max-height from 12rem (192px) to 18rem (288px) so the
log/console/files content area gets the same +50% vertical breathing
room the tab buttons themselves received in eabb976.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The .tab-pane[data-tab="console"] flex rule added in eabb976 had equal
specificity to .tab-pane[hidden] { display: none } and came later in
the cascade, so the hidden console pane rendered display:flex right
underneath the active log pane — the page showed both at once,
doubling the inspection strip's height. Adding :not([hidden]) keeps
the hidden rule winning when the tab is inactive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tab padding: --space-xs → --space-s (vertical, 2×) and --space-m → calc(--space-m * 1.5) (horizontal, +50%), plus font-size: 1.05em. strip-expand bumped from --space-xs/--space-s to --space-s/--space-m proportionally. Console panes (inline tab-pane and modal body) are now flex columns so the transcript scrolls and the input form stays pinned at the bottom; max-height: none overrides the global 400px cap in both scopes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix 1: add .modal .log-stream.tall / .console-transcript.tall → max-height 60vh so
log and console modals render taller than the compact inline tab
- Fix 2: replace len(recent_rows) with a select(func.count(func.distinct(...))) so
recent_players_total_count reflects all matching players, not the .limit(50) cap;
add test_live_state_total_count_reflects_truth_above_limit (60 sessions → "60 Recent")
- Fix 3: dispatch custom modal:opened event after showModal() in both openInline and
fetchAndShowRouted; switch recent-players-modal hx-trigger from "revealed" to
"modal:opened from:closest dialog" so HTMX re-fetches on every open, not just first.
Manual smoke-test not performed — relies on JS event dispatch + test suite; no JS
test framework in repo.
- Fix 4: remove dead config_field macro (value-form, never called; config_field_block
is the one actually used)
- Fix 5: drop unused editable parameter from config_field_block macro definition and
the editable=True call on the Hostname field
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Append two new e2e tests that cover the inspection strip:
- test_tabs_switch_between_log_console_files: verifies data-active-tab
attribute mirrors tab clicks and the correct tabpanel is shown/hidden
- test_expand_opens_matching_modal: verifies the ⛶ button opens the
<dialog> matching the active tab name
Also fix the pre-existing test_hover_download_initiates_file_download
for the new tab-based layout: click the Files tab first (rows were
inside a hidden tabpanel), and scope the row locator to the tab pane
to avoid a strict-mode violation now that the modal also contains a
duplicate file tree.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds the _recent_players_modal_body.html partial for the full recent-players
list (no 10-item cap), the route branch in live_state_fragment that renders it
when ?view=recent-modal is requested, and the .recent-modal-list CSS rule that
forces single-column layout inside the modal.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace four raw-string | safe config_field calls with {% call config_field_block %}
blocks so Jinja auto-escaping is preserved for server.hostname, server.name,
blueprint.name, server.rcon_password and g.user.username. Extract a console_form
macro to eliminate the duplicated inline/modal form and restore the missing
placeholder on the modal input. Add XSS regression test that confirms the fix
is load-bearing (test fails when templates are reverted to pre-fix state).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Drop .limit(20) from the recent_rows query so the full history window is
available for the future recent-players modal; derive recent_players_overview
(first 10) and recent_players_total_count from the unbounded result and pass
both into _live_state.html alongside the existing recent_players key.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Groups server state into a single top cluster (lifecycle + live state
+ config), demotes log/console/files to a tabbed inspection strip with
expand-to-modal, and routes the job log behind a modal so the page no
longer reflows during HTMX polls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three labelled swatches mirror the dropdown's name colors so users
can decode the cvar/command/sourcemod color scheme without guesswork.
Plain-text caveat next to the sourcemod swatch notes that those
commands are plugin-dependent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sm_* commands depend on the SourceMod plugin being loaded on the
target server, which is not always the case. Render their names in
the third syntax-palette color (purple via --cm-number) so the user
can tell at a glance that these may not exist on the server they
are targeting. Vanilla cvars and commands keep their existing
pink/green colors. Theme-aware via the existing token swap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New test module (test_server_detail.py) — the server-detail page is
NOT a files-overlay (files_overlay=False in the template), it just
reuses _overlay_file_tree.html in read-only mode. Tests live
separately to make the semantic split visible.
The test navigates to /servers/<id>, hovers the server.cfg row to
defeat the CSS :hover gate on .files-row-actions
(opacity:0/pointer-events:none → 1/auto), clicks the ⬇ download
link, and asserts both the suggested filename and the byte content
of the downloaded file.
The :hover gate is load-bearing: without locator.hover() first,
pointer-events:none blocks the click. A regression that ships
actions always-visible would change the user-flow ergonomics and
needs to update this test deliberately.
Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md
(Tier 3).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>