Six-task TDD plan that turns the 2026-05-18 redesign spec into concrete steps: expand_collections backend helper + tests, handler unification (drop input_mode), CSS component primitives, create-modal template rewrite + e2e test, workshop-section template rewrite + fixture + e2e tests, final stale-content sweep. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
48 KiB
Create Overlay Modal + Workshop Items Section — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Redesign the Create-overlay modal and the Workshop items section per docs/superpowers/specs/2026-05-18-create-modal-and-workshop-section-redesign.md. Two surfaces are touched: a Flask/Jinja template restyle on the front, and a small handler+helper change on the back (drop the items-vs-collection mode radio, autodetect server-side).
Architecture: Backend: add expand_collections() to steam_workshop.py (one batched GetCollectionDetails call that turns a mixed list of IDs into a flat item-ID list); unify routes/workshop_routes.py:add_items to call it. Frontend: add reusable component CSS primitives (.field, .radio-row, .radio-list, .switch, .switch-row, .table-actions); rewrite the create-overlay <dialog> body and the workshop section to use them.
Tech Stack: Flask, Jinja2 templates, vanilla CSS (no Tailwind — see user memory feedback_no_tailwind.md), SQLAlchemy, requests for Steam API, pytest with mocked _session(), Playwright e2e for UI verification.
File map
| File | Action | Why |
|---|---|---|
l4d2web/l4d2web/services/steam_workshop.py |
Add expand_collections() |
Batched autodetect of collections-vs-items in one Steam round-trip. |
l4d2web/tests/test_steam_workshop.py |
Add tests for expand_collections |
TDD; matches existing per-function test style. |
l4d2web/l4d2web/routes/workshop_routes.py |
Replace add_items body |
Drop input_mode branching; call expand_collections; preserve "no items" error message. |
l4d2web/tests/test_workshop_routes.py |
Update existing tests, add autodetect test | Remove now-irrelevant input_mode field from POST bodies; assert autodetect path. |
l4d2web/l4d2web/static/css/components.css |
Append new component rules | Reusable form/control primitives — adopted now by these two surfaces. |
l4d2web/l4d2web/templates/overlays.html |
Rewrite create-overlay modal body | Reorder fields, drop fieldset, use new primitives, drop legacy path hint. |
l4d2web/l4d2web/templates/overlay_detail.html |
Rewrite workshop items section | Drop input-mode radio; relocate refresh button below table; add summary row. |
l4d2web/tests/e2e/test_overlays_create.py |
New e2e test | Verify the redesigned create modal renders and submits correctly. |
l4d2web/tests/e2e/test_workshop_section.py |
New e2e test | Verify the redesigned workshop section renders + the autodetect path works. |
The .modal* → .dialog* rename mentioned in the spec is out of scope here — five other dialogs on the overlay-detail page (rename, delete, files-new-folder, files-conflict, files-delete) share the same classes. Renaming is a separate, repo-wide change and lands in its own commit. Surface it as a follow-up.
Task 1 — Add expand_collections() helper to steam_workshop.py
Files:
- Modify:
l4d2web/l4d2web/services/steam_workshop.py - Test:
l4d2web/tests/test_steam_workshop.py
The existing resolve_collection(collection_id) handles a single ID. We add a batched variant that takes a list of mixed IDs, performs one GetCollectionDetails POST for all of them, and returns a flat list where collection inputs are replaced by their child IDs and non-collection inputs are passed through. Nested collections (filetype != 0) inside children are skipped, matching the existing convention.
The signal we use to identify "this ID is not a collection" comes from the real Steam API behavior we verified during brainstorming: GetCollectionDetails returns result: 9 (k_EResultFileNotFound) when called on a non-collection ID, and result: 1 with a children array when called on a collection.
- Step 1.1: Write the failing tests for
expand_collections
Add to l4d2web/tests/test_steam_workshop.py at the end of the file:
def test_expand_collections_passes_through_items() -> None:
fake_response = MagicMock(status_code=200)
fake_response.raise_for_status = MagicMock()
fake_response.json.return_value = {
"response": {
"collectiondetails": [
{"publishedfileid": "1001", "result": 9},
{"publishedfileid": "1002", "result": 9},
]
}
}
with patch.object(steam_workshop, "_session", return_value=MagicMock(post=MagicMock(return_value=fake_response))):
result = steam_workshop.expand_collections(["1001", "1002"])
assert result == ["1001", "1002"]
def test_expand_collections_replaces_collection_with_children() -> None:
fake_response = MagicMock(status_code=200)
fake_response.raise_for_status = MagicMock()
fake_response.json.return_value = {
"response": {
"collectiondetails": [
{
"publishedfileid": "555",
"result": 1,
"children": [
{"publishedfileid": "1001", "filetype": 0},
{"publishedfileid": "1002", "filetype": 0},
],
},
]
}
}
with patch.object(steam_workshop, "_session", return_value=MagicMock(post=MagicMock(return_value=fake_response))):
result = steam_workshop.expand_collections(["555"])
assert result == ["1001", "1002"]
def test_expand_collections_mixed_items_and_collections() -> None:
fake_response = MagicMock(status_code=200)
fake_response.raise_for_status = MagicMock()
fake_response.json.return_value = {
"response": {
"collectiondetails": [
{"publishedfileid": "1001", "result": 9},
{
"publishedfileid": "555",
"result": 1,
"children": [
{"publishedfileid": "2001", "filetype": 0},
{"publishedfileid": "2002", "filetype": 0},
],
},
{"publishedfileid": "1003", "result": 9},
]
}
}
with patch.object(steam_workshop, "_session", return_value=MagicMock(post=MagicMock(return_value=fake_response))):
result = steam_workshop.expand_collections(["1001", "555", "1003"])
# Input order preserved; collection's children inserted where the collection was.
assert result == ["1001", "2001", "2002", "1003"]
def test_expand_collections_skips_nested_collections() -> None:
fake_response = MagicMock(status_code=200)
fake_response.raise_for_status = MagicMock()
fake_response.json.return_value = {
"response": {
"collectiondetails": [
{
"publishedfileid": "555",
"result": 1,
"children": [
{"publishedfileid": "1001", "filetype": 0},
{"publishedfileid": "9999", "filetype": 1}, # nested collection
{"publishedfileid": "1002", "filetype": 0},
],
},
]
}
}
with patch.object(steam_workshop, "_session", return_value=MagicMock(post=MagicMock(return_value=fake_response))):
result = steam_workshop.expand_collections(["555"])
assert result == ["1001", "1002"]
def test_expand_collections_deduplicates() -> None:
fake_response = MagicMock(status_code=200)
fake_response.raise_for_status = MagicMock()
fake_response.json.return_value = {
"response": {
"collectiondetails": [
{"publishedfileid": "1001", "result": 9},
{
"publishedfileid": "555",
"result": 1,
"children": [
{"publishedfileid": "1001", "filetype": 0}, # duplicate of pass-through
{"publishedfileid": "1002", "filetype": 0},
],
},
]
}
}
with patch.object(steam_workshop, "_session", return_value=MagicMock(post=MagicMock(return_value=fake_response))):
result = steam_workshop.expand_collections(["1001", "555"])
assert result == ["1001", "1002"]
def test_expand_collections_empty_input_returns_empty() -> None:
result = steam_workshop.expand_collections([])
assert result == []
def test_expand_collections_rejects_non_numeric_ids() -> None:
with pytest.raises(ValueError):
steam_workshop.expand_collections(["1001", "not-a-number"])
- Step 1.2: Run the tests to verify they fail
Run:
cd /Users/mwiegand/Projekte/left4me/l4d2web && uv run pytest tests/test_steam_workshop.py -v -k expand_collections
Expected: 7 FAIL with AttributeError: module 'l4d2web.services.steam_workshop' has no attribute 'expand_collections'.
- Step 1.3: Implement
expand_collections
Add this function to l4d2web/l4d2web/services/steam_workshop.py, immediately after the existing resolve_collection function (around line 141):
def expand_collections(ids: list[str]) -> list[str]:
"""Resolve a mix of item and collection IDs into a flat list of item IDs.
Performs one batched POST to GetCollectionDetails. For each input ID:
- If Steam returns result==1 with a children array, the ID is a
collection — replace it with its non-nested child item IDs in order.
- If Steam returns result==9 (k_EResultFileNotFound), the ID is not a
collection — pass it through unchanged.
Result preserves input order; collection children are inserted at the
position the collection ID held. Duplicates (across pass-throughs and
expanded children) are removed, keeping first occurrence.
"""
if not ids:
return []
for sid in ids:
if not _NUMERIC_ID_RE.fullmatch(sid):
raise ValueError(f"steam id must be digits only: {sid!r}")
payload: dict[str, str | int] = {"collectioncount": len(ids)}
for index, sid in enumerate(ids):
payload[f"publishedfileids[{index}]"] = sid
response = _session().post(
GET_COLLECTION_DETAILS_URL,
data=payload,
timeout=REQUEST_TIMEOUT_SECONDS,
)
response.raise_for_status()
body = response.json()
by_id: dict[str, dict] = {
str(entry.get("publishedfileid", "")): entry
for entry in body.get("response", {}).get("collectiondetails", [])
}
expanded: list[str] = []
seen: set[str] = set()
def _add(sid: str) -> None:
if sid and sid not in seen:
seen.add(sid)
expanded.append(sid)
for sid in ids:
entry = by_id.get(sid)
if entry and entry.get("result") == 1 and "children" in entry:
for child in entry["children"]:
if child.get("filetype", 0) != 0:
continue # nested collection — skip
child_id = child.get("publishedfileid")
if child_id is not None:
_add(str(child_id))
else:
_add(sid)
return expanded
- Step 1.4: Run the tests to verify they pass
Run:
cd /Users/mwiegand/Projekte/left4me/l4d2web && uv run pytest tests/test_steam_workshop.py -v -k expand_collections
Expected: 7 PASS.
- Step 1.5: Run the entire
test_steam_workshop.pyto catch regressions
Run:
cd /Users/mwiegand/Projekte/left4me/l4d2web && uv run pytest tests/test_steam_workshop.py -v
Expected: all tests in the file PASS (existing + new).
- Step 1.6: Commit
cd /Users/mwiegand/Projekte/left4me
git add l4d2web/l4d2web/services/steam_workshop.py l4d2web/tests/test_steam_workshop.py
git commit -m "$(cat <<'EOF'
feat(workshop): batched expand_collections() helper
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>
EOF
)"
Task 2 — Unify add_items handler: drop input_mode, autodetect
Files:
- Modify:
l4d2web/l4d2web/routes/workshop_routes.py:36-99 - Modify:
l4d2web/tests/test_workshop_routes.py
Drop the request.form.get("input_mode") branching. The new handler always: (1) parses the input, (2) batch-calls expand_collections, (3) batch-fetches metadata, (4) persists. The form-field name input_mode stops being read; the template still sends it for one more deploy (we remove it in Task 5).
- Step 2.1: Update existing tests to drop
input_modeand add a focused autodetect test
Open l4d2web/tests/test_workshop_routes.py. The existing tests pass "input_mode": "items" or "input_mode": "collection" to the POST data. The "items" cases simply have a redundant field — we'll leave them in for one test run to prove backward compatibility, then strip in Step 2.6. The collection case needs full rewrite.
First, rewrite test_add_collection_resolves_members (currently around line 133-150). Replace its body with:
def test_add_collection_autodetects_and_expands_children(overlay_for):
"""Pasting a collection ID expands to its children via autodetect — no
input_mode field is needed in the request."""
app, login, user_id, _admin_id, overlay_id = overlay_for
user_client = login(user_id)
with patch.object(
steam_workshop, "expand_collections", return_value=["1001", "1002", "1003"]
), _patch_steam([_meta("1001"), _meta("1002"), _meta("1003")]):
response = user_client.post(
f"/overlays/{overlay_id}/items",
data={"input": "555"}, # no input_mode field
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 302
with session_scope() as session:
steam_ids = {wi.steam_id for wi in session.query(WorkshopItem).all()}
assert steam_ids == {"1001", "1002", "1003"}
Then add a new test alongside it:
def test_add_mixed_items_and_collection_in_one_paste(overlay_for):
"""A single submission can mix item IDs, item URLs, and a collection URL;
expand_collections flattens collections in place, and metadata fetch covers
the resulting flat ID list."""
app, login, user_id, _admin_id, overlay_id = overlay_for
user_client = login(user_id)
# User pastes: one bare item, one collection URL, one item URL.
raw = (
"1001\n"
"https://steamcommunity.com/sharedfiles/filedetails/?id=555\n"
"https://steamcommunity.com/sharedfiles/filedetails/?id=2001\n"
)
# expand_collections sees [1001, 555, 2001] → returns [1001, 3001, 3002, 2001]
with patch.object(
steam_workshop, "expand_collections", return_value=["1001", "3001", "3002", "2001"]
), _patch_steam([_meta(s) for s in ("1001", "3001", "3002", "2001")]):
response = user_client.post(
f"/overlays/{overlay_id}/items",
data={"input": raw},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 302
with session_scope() as session:
steam_ids = {wi.steam_id for wi in session.query(WorkshopItem).all()}
assert steam_ids == {"1001", "2001", "3001", "3002"}
- Step 2.2: Run the modified+new tests, expect them to fail
Run:
cd /Users/mwiegand/Projekte/left4me/l4d2web && uv run pytest tests/test_workshop_routes.py::test_add_collection_autodetects_and_expands_children tests/test_workshop_routes.py::test_add_mixed_items_and_collection_in_one_paste -v
Expected: both FAIL — the route still uses input_mode and doesn't call expand_collections.
- Step 2.3: Rewrite the
add_itemshandler
In l4d2web/l4d2web/routes/workshop_routes.py, replace lines 36–99 (the entire add_items function) with:
@bp.post("/overlays/<int:overlay_id>/items")
@require_login
def add_items(overlay_id: int) -> Response:
user = current_user()
assert user is not None
raw_input = request.form.get("input", "").strip()
if not raw_input:
return Response("missing input", status=400)
try:
ids = steam_workshop.parse_workshop_input(raw_input)
except ValueError as exc:
return Response(str(exc), status=400)
try:
ids = steam_workshop.expand_collections(ids)
except requests.RequestException as exc:
return Response(f"steam api error: {exc}", status=502)
if not ids:
return Response("no items to add (collections may be empty)", status=400)
try:
metas = steam_workshop.fetch_metadata_batch(ids, mode="add")
except steam_workshop.WorkshopValidationError as exc:
return Response(str(exc), status=400)
except requests.RequestException as exc:
return Response(f"steam api error: {exc}", status=502)
with session_scope() as db:
overlay, err = _check_workshop_overlay_access(overlay_id, user, db)
if err is not None:
return err
for meta in metas:
wi = db.scalar(select(WorkshopItem).where(WorkshopItem.steam_id == meta.steam_id))
if wi is None:
wi = WorkshopItem(steam_id=meta.steam_id)
db.add(wi)
wi.title = meta.title
wi.filename = meta.filename
wi.file_url = meta.file_url
wi.file_size = meta.file_size
wi.time_updated = meta.time_updated
wi.preview_url = meta.preview_url
wi.last_error = "" if meta.result == 1 else f"steam result {meta.result}"
db.flush()
existing = db.scalar(
select(OverlayWorkshopItem).where(
OverlayWorkshopItem.overlay_id == overlay_id,
OverlayWorkshopItem.workshop_item_id == wi.id,
)
)
if existing is None:
db.add(OverlayWorkshopItem(overlay_id=overlay_id, workshop_item_id=wi.id))
job = enqueue_build_overlay(db, overlay_id=overlay_id, user_id=user.id)
job_id = job.id
return redirect(f"/jobs/{job_id}")
Note: the broader except Exception as exc: return Response("steam api error...", 502) was narrowed to requests.RequestException. Anything else propagates as 500, which is what we want.
- Step 2.4: Run the two failing tests; they should now pass
Run:
cd /Users/mwiegand/Projekte/left4me/l4d2web && uv run pytest tests/test_workshop_routes.py::test_add_collection_autodetects_and_expands_children tests/test_workshop_routes.py::test_add_mixed_items_and_collection_in_one_paste -v
Expected: both PASS.
- Step 2.5: Run the whole
test_workshop_routes.pyto catch regressions
Run:
cd /Users/mwiegand/Projekte/left4me/l4d2web && uv run pytest tests/test_workshop_routes.py -v
Expected: every test PASSES. The existing tests pass "input_mode": "items" in their POST data, which is now ignored by the handler — they should still work because the items path is now the default. If any of those tests rely on _patch_steam being called but not expand_collections, they'll fail because expand_collections makes a real HTTP call. Fix by adding patch.object(steam_workshop, "expand_collections", side_effect=lambda x: x) to those tests as needed.
Concretely: search the file for _patch_steam( — every test that uses it now also needs an expand_collections patch. Update the helper to encompass this:
Replace the existing _patch_steam helper near the top of the file:
def _patch_steam(metas: Iterable[steam_workshop.WorkshopMetadata]):
return patch.object(steam_workshop, "fetch_metadata_batch", return_value=list(metas))
with this version that also stubs expand_collections to a pass-through (identity), so existing item-only tests don't make real Steam calls:
def _patch_steam(metas: Iterable[steam_workshop.WorkshopMetadata]):
from contextlib import ExitStack
stack = ExitStack()
stack.enter_context(patch.object(steam_workshop, "expand_collections", side_effect=lambda x: list(x)))
stack.enter_context(patch.object(steam_workshop, "fetch_metadata_batch", return_value=list(metas)))
return stack
Re-run the whole file to confirm everything is green.
- Step 2.6: Strip the now-irrelevant
input_modefield from POST data in existing tests
In test_workshop_routes.py, search-and-replace every occurrence of "input_mode": "items" (and its trailing comma if present) to remove the field. The handler no longer reads it. Examples:
Before:
data={"input": "1001", "input_mode": "items"},
After:
data={"input": "1001"},
Run the full file once more after the sweep:
cd /Users/mwiegand/Projekte/left4me/l4d2web && uv run pytest tests/test_workshop_routes.py -v
Expected: all PASS.
- Step 2.7: Commit
cd /Users/mwiegand/Projekte/left4me
git add l4d2web/l4d2web/routes/workshop_routes.py l4d2web/tests/test_workshop_routes.py
git commit -m "$(cat <<'EOF'
refactor(workshop): autodetect collections; drop input_mode form field
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; one new test covers a mixed paste.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 3 — Component CSS primitives
Files:
- Modify:
l4d2web/l4d2web/static/css/components.css
Add the small set of reusable form/control primitives. Use existing tokens from tokens.css (already verified sufficient). No new tokens.
- Step 3.1: Append the new component rules to
components.css
Add this block at the very end of l4d2web/l4d2web/static/css/components.css:
/* --- Form primitives (new vocabulary, 2026-05) -------------------------- */
.field {
display: grid;
gap: var(--space-xs);
}
.field-label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text);
}
/* .field-hint already defined elsewhere in this file. */
.radio-list {
display: grid;
gap: var(--space-xs);
}
.radio-row {
display: flex;
align-items: flex-start;
gap: var(--space-s);
padding: var(--space-xs) 0;
cursor: pointer;
}
.radio-row > input[type="radio"] {
appearance: none;
width: 1rem;
height: 1rem;
border-radius: 50%;
border: 1.5px solid var(--color-border);
background: var(--color-bg);
flex: 0 0 auto;
margin: 0.2rem 0 0 0;
display: grid;
place-items: center;
cursor: pointer;
}
.radio-row > input[type="radio"]:checked {
border-color: var(--color-primary);
}
.radio-row > input[type="radio"]:checked::after {
content: "";
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--color-primary);
}
.radio-row > input[type="radio"]:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
.radio-row-text {
display: grid;
gap: 0.0625rem;
}
.radio-row-text strong {
font-weight: 600;
font-size: 0.9375rem;
color: var(--color-text);
}
.radio-row-text span {
color: var(--color-muted);
font-size: 0.8125rem;
}
.switch-row {
display: flex;
align-items: flex-start;
gap: var(--space-s);
padding: var(--space-xs) 0;
cursor: pointer;
}
.switch-row > input[type="checkbox"] {
appearance: none;
position: relative;
width: 1.875rem;
height: 1rem;
background: var(--color-border);
border-radius: 999px;
flex: 0 0 auto;
margin: 0.225rem 0 0 0;
cursor: pointer;
transition: background 0.15s;
}
.switch-row > input[type="checkbox"]::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 0.75rem;
height: 0.75rem;
background: #fff;
border-radius: 50%;
transition: transform 0.15s;
}
.switch-row > input[type="checkbox"]:checked {
background: var(--color-button-primary);
}
.switch-row > input[type="checkbox"]:checked::after {
transform: translateX(0.875rem);
}
.switch-row > input[type="checkbox"]:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
.switch-row-text {
display: grid;
gap: 0.0625rem;
}
.switch-row-text strong {
font-weight: 600;
font-size: 0.9375rem;
color: var(--color-text);
}
.switch-row-text span {
color: var(--color-muted);
font-size: 0.8125rem;
}
.table-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--space-m);
}
- Step 3.2: Verify CSS file is still valid (no syntax errors)
Run:
cd /Users/mwiegand/Projekte/left4me && python3 -c "
import re
css = open('l4d2web/l4d2web/static/css/components.css').read()
# Quick brace-balance check
opens = css.count('{')
closes = css.count('}')
assert opens == closes, f'Unbalanced braces: {opens} open, {closes} close'
print(f'OK: {opens} balanced braces, {len(css)} chars')
"
Expected: OK: <N> balanced braces, <M> chars.
- Step 3.3: Commit
cd /Users/mwiegand/Projekte/left4me
git add l4d2web/l4d2web/static/css/components.css
git commit -m "$(cat <<'EOF'
feat(css): add .field/.radio-row/.switch-row/.table-actions primitives
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>
EOF
)"
Task 4 — Redesign Create-overlay modal template
Files:
- Modify:
l4d2web/l4d2web/templates/overlays.html(modal block, lines 29-54)
Reorder fields (Name → Type → System-wide), drop the fieldset, swap to the new component vocabulary, drop the legacy path-hint paragraph. The form-field NAMES stay the same (name, type, system_wide) so the existing overlay-creation handler doesn't change.
- Step 4.1: Rewrite the
<dialog id="create-overlay-modal">body
In l4d2web/l4d2web/templates/overlays.html, replace lines 29–54 with:
<dialog id="create-overlay-modal" class="modal" aria-labelledby="create-overlay-title">
<form method="post" action="/overlays" class="stack">
<div class="modal-header">
<h2 id="create-overlay-title">Create overlay</h2>
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">×</button>
</div>
<div class="modal-body">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<div class="field">
<label class="field-label" for="create-overlay-name">Name</label>
<input id="create-overlay-name" name="name" required>
</div>
<div class="field">
<span class="field-label">Type</span>
<div class="radio-list">
<label class="radio-row">
<input type="radio" name="type" value="workshop" checked>
<span class="radio-row-text">
<strong>Workshop</strong>
<span>Downloads from Steam</span>
</span>
</label>
<label class="radio-row">
<input type="radio" name="type" value="script">
<span class="radio-row-text">
<strong>Script</strong>
<span>Runs sandboxed bash</span>
</span>
</label>
<label class="radio-row">
<input type="radio" name="type" value="files">
<span class="radio-row-text">
<strong>Files</strong>
<span>Upload / edit text files online</span>
</span>
</label>
</div>
</div>
{% if g.user and g.user.admin %}
<label class="switch-row">
<input type="checkbox" name="system_wide" value="1">
<span class="switch-row-text">
<strong>System-wide</strong>
<span>Visible to all users</span>
</span>
</label>
{% endif %}
</div>
<div class="modal-footer">
<button type="button" class="button-secondary" data-inline-modal-close>Cancel</button>
<button type="submit">Create</button>
</div>
</form>
</dialog>
Notes for the implementing engineer:
-
The hidden
csrf_tokeninput stays inside<div class="modal-body">— same position as before. -
The Name field is wrapped in
<div class="field">with an explicit<label for>(previously it was an implicit wrapper-label). The form field name is stillname. -
The Type radios now use the
.radio-rowpattern. Each<label class="radio-row">wraps the input + a<span class="radio-row-text">with bold label and muted description. -
The System-wide checkbox is now
.switch-rowwith the same label pattern. The form field name is stillsystem_wideand the value is still"1". The{% if g.user and g.user.admin %}gate is preserved verbatim. -
The legacy
<p class="muted">The path is generated automatically.</p>is gone. -
Step 4.2: Write the e2e test before manual verification
The repo's e2e tests use the live_server fixture from l4d2web/tests/e2e/conftest.py — it boots the Flask app on an ephemeral port and seeds user alice (password secret). The login helper is also exported from conftest. Built-in Playwright fixture page provides the browser tab.
Create l4d2web/tests/e2e/test_overlays_create.py:
"""E2E test for the redesigned create-overlay modal."""
from __future__ import annotations
import pytest
from playwright.sync_api import Page, expect
from .conftest import login
pytestmark = pytest.mark.e2e
def test_create_overlay_modal_field_order_and_submission(page: Page, live_server) -> None:
base_url = live_server["base_url"]
login(page, base_url)
page.goto(f"{base_url}/overlays")
page.click('button[data-inline-modal-open="create-overlay-modal"]')
modal = page.locator("#create-overlay-modal")
expect(modal).to_be_visible()
# Field order: Name input must appear before Type radios in DOM.
name_input = modal.locator('input[name="name"]')
type_workshop = modal.locator('input[name="type"][value="workshop"]')
expect(name_input).to_be_visible()
expect(type_workshop).to_be_visible()
name_box = name_input.bounding_box()
type_box = type_workshop.bounding_box()
assert name_box is not None and type_box is not None
assert name_box["y"] < type_box["y"], "Name should sit above Type"
# No legacy fieldset border around Type group.
expect(modal.locator("fieldset.overlay-type-radio")).to_have_count(0)
# No legacy "path is generated automatically" copy anywhere.
expect(modal).not_to_contain_text("path is generated automatically")
# Default workshop selection is checked.
expect(type_workshop).to_be_checked()
# Submit with a unique name and the script type.
name_input.fill("e2e-test-overlay")
modal.locator('input[name="type"][value="script"]').check()
modal.locator('button[type="submit"]:has-text("Create")').click()
# Handler redirects to /overlays/<id> on success.
page.wait_for_url("**/overlays/*", timeout=5000)
expect(page.locator("h1")).to_contain_text("e2e-test-overlay")
(If the create-overlay submission redirect actually goes back to /overlays rather than to /overlays/<id>, adjust the wait_for_url and assertion accordingly. Run the test, observe the behavior in the failure trace, and tighten the expectation to whatever the handler genuinely does.)
- Step 4.3: Start the dev server and run the e2e test
In one terminal:
cd /Users/mwiegand/Projekte/left4me/l4d2web && uv run python scripts/dev-server.py
(Per user memory reference_left4me_dev_server.md: use scripts/dev-server.py, not plain flask run — the latter misroutes LEFT4ME_ROOT on macOS.)
In a second terminal:
cd /Users/mwiegand/Projekte/left4me/l4d2web && uv run pytest tests/e2e/test_overlays_create.py -v
Expected: PASS.
- Step 4.4: Manual visual smoke test
While the dev server is up, open http://localhost:5051/overlays in a browser, click "Create overlay", and confirm by eye:
- Field order is Name (input) → Type (three radios with descriptions) → System-wide (only if admin; switch + text).
- No bordered/fieldset box around Type.
- Radio dots are circular with a primary-color filled inner dot when selected; clicking changes selection.
- Switch animates left↔right when toggled.
- No "The path is generated automatically." paragraph.
- Cancel + Create buttons in the footer, right-aligned, with the muted background strip beneath.
If anything looks wrong, fix the template inline (the CSS is already in place from Task 3) and re-verify.
- Step 4.5: Commit
cd /Users/mwiegand/Projekte/left4me
git add l4d2web/l4d2web/templates/overlays.html l4d2web/tests/e2e/test_overlays_create.py
git commit -m "$(cat <<'EOF'
style(overlays): redesign create-overlay modal
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.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 5 — Redesign Workshop items section
Files:
- Modify:
l4d2web/l4d2web/templates/overlay_detail.html:43-67
Drop the input-mode fieldset, keep the textarea as a .field block, put the Add button in its own right-aligned row immediately under the textarea, and move "Refresh from Steam" out of the standalone form into a .table-actions row that sits below the items-table container with a summary span on the left.
- Step 5.1: Rewrite the workshop section block
In l4d2web/l4d2web/templates/overlay_detail.html, replace lines 43–67 (the entire {% if overlay.type == 'workshop' %} ... {% endif %} block) with:
{% if overlay.type == 'workshop' %}
<h2 class="section-title">Workshop items</h2>
{% if can_edit and not latest_build_is_running %}
<form method="post" action="/overlays/{{ overlay.id }}/items" class="stack">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<div class="field">
<label class="field-label" for="workshop-input">Add items</label>
<p class="field-hint">Paste Steam Workshop IDs, item URLs, or collection URLs — one per line. Collections expand automatically.</p>
<textarea id="workshop-input" name="input" rows="3" class="workshop-input"
placeholder="3726529483 https://steamcommunity.com/sharedfiles/filedetails/?id=3724125665"></textarea>
</div>
<div class="form-actions-inline" style="justify-content: flex-end">
<button type="submit">Add</button>
</div>
</form>
{% endif %}
<div id="overlay-item-table">
{% include "_overlay_item_table.html" with context %}
</div>
{% if can_edit and not latest_build_is_running %}
<div class="table-actions">
<span class="field-hint">{% if workshop_items_count %}{{ workshop_items_count }} item{{ "s" if workshop_items_count != 1 else "" }}{% if workshop_items_total_size %} · {{ workshop_items_total_size }} total{% endif %}{% else %}0 items{% endif %}</span>
<form method="post" action="/overlays/{{ overlay.id }}/refresh" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button type="submit" {% if not workshop_items_count %}disabled{% endif %}>↻ Refresh from Steam</button>
</form>
</div>
{% endif %}
{% include "_overlay_build_status.html" %}
{% endif %}
Notes for the implementing engineer:
-
The textarea uses a new
.workshop-inputclass for the monospace font tweak (added in Step 5.2 below). -
The
placeholdershows one bare item ID and one collection URL — two examples reinforcing the autodetect promise. -
The Refresh form keeps
inline-form(existing class) so its child button isn't wrapped in a stack/grid container — it sits flush against the summary span. -
Two new template variables are referenced:
workshop_items_countandworkshop_items_total_size. The route handler that rendersoverlay_detail.htmlwill need to compute these. Step 5.3 handles that. Until then, the template will render with both as Falsy → "0 items" in the summary, which is wrong but safe. -
The standalone
<form class="stack workshop-refresh-form">from the old markup is gone — the form now lives inline within.table-actions. -
Step 5.2: Add
.workshop-inputmono-font rule tocomponents.css
Append to l4d2web/l4d2web/static/css/components.css:
.workshop-input {
font-family: var(--font-mono);
font-size: 0.875rem;
}
- Step 5.3: Plumb
workshop_items_countandworkshop_items_total_sizeinto the template context
The handler that renders overlay_detail.html is overlay_detail() in l4d2web/l4d2web/routes/page_routes.py:485. It already loads workshop_items into a local variable (lines 503-513) and passes it via render_template(...) at line 518. We just need to derive the count and total-size and add them to the kwargs.
First, check whether the project has a humanize helper already:
cd /Users/mwiegand/Projekte/left4me/l4d2web && grep -rn "humaniz\|format_bytes\|human_bytes" l4d2web/services/ l4d2web/util*/ 2>/dev/null
If a helper exists (likely in l4d2web/services/timeago.py or a similar util module), import and use it. Otherwise add the helper inline in page_routes.py just above the overlay_detail function:
def _humanize_bytes(n: int) -> str:
if n < 1024:
return f"{n} B"
size = float(n)
for unit in ("KB", "MB", "GB"):
size /= 1024
if size < 1024 or unit == "GB":
return f"{size:.1f} {unit}"
return f"{size:.1f} GB"
Then modify the render_template call in overlay_detail() (currently lines 518-529). Add the two new variables. The relevant section becomes:
total_bytes = sum((wi.file_size or 0) for wi in workshop_items)
workshop_items_total_size = _humanize_bytes(total_bytes) if total_bytes else ""
return render_template(
"overlay_detail.html",
overlay=overlay,
using_blueprints=using_blueprints,
workshop_items=workshop_items,
workshop_items_count=len(workshop_items),
workshop_items_total_size=workshop_items_total_size,
file_tree_root_entries=file_tree_root_entries,
file_tree_truncated=file_tree_truncated_count > 0
if file_tree_root_entries is not None
else False,
file_tree_truncated_count=file_tree_truncated_count,
**build_ctx,
)
The two new lines sit before return render_template(; the two new kwargs slot into the call beside workshop_items=....
- Step 5.4: Add a
workshop_overlay_serverfixture to conftest.py
The e2e conftest at l4d2web/tests/e2e/conftest.py has fixtures live_server, files_overlay_server, and server_with_files — but no workshop-overlay equivalent. Add one alongside them, modeled on files_overlay_server (lines 109-166) but creating an Overlay with type="workshop":
@pytest.fixture(scope="function")
def workshop_overlay_server(tmp_path, monkeypatch):
"""live_server + a workshop-type Overlay owned by alice. The overlay
starts with zero items — tests that need items should seed them via
direct DB writes or via UI actions inside the test."""
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
app = _boot_app(tmp_path, monkeypatch)
with session_scope() as session:
user = User(
username="alice",
password_digest=hash_password("secret"),
admin=False,
)
session.add(user)
session.flush()
overlay = Overlay(
name="my-maps",
path="_pending",
type="workshop",
user_id=user.id,
)
session.add(overlay)
session.flush()
overlay.path = str(overlay.id)
user_id = user.id
overlay_id = overlay.id
base_url, shutdown = _serve(app)
try:
yield {
"base_url": base_url,
"user_id": user_id,
"overlay_id": overlay_id,
}
finally:
shutdown()
- Step 5.5: Write the e2e tests for the redesigned workshop section
Create l4d2web/tests/e2e/test_workshop_section.py:
"""E2E test for the redesigned workshop items section."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from playwright.sync_api import Page, expect
from l4d2web.services import steam_workshop
from .conftest import login
pytestmark = pytest.mark.e2e
def _meta(steam_id: str) -> steam_workshop.WorkshopMetadata:
return steam_workshop.WorkshopMetadata(
steam_id=steam_id,
title=f"Item {steam_id}",
filename=f"{steam_id}.vpk",
file_url=f"https://example.com/{steam_id}.vpk",
file_size=1024,
time_updated=1700000000,
preview_url="",
consumer_app_id=550,
result=1,
)
def test_workshop_section_renders_without_input_mode_radio(page: Page, workshop_overlay_server) -> None:
base_url = workshop_overlay_server["base_url"]
overlay_id = workshop_overlay_server["overlay_id"]
login(page, base_url)
page.goto(f"{base_url}/overlays/{overlay_id}")
# Legacy input-mode fieldset is gone.
expect(page.locator("fieldset.workshop-input-mode")).to_have_count(0)
expect(page).not_to_contain_text("Input mode")
expect(page).not_to_contain_text("Items (paste IDs or URLs; one or many)")
expect(page).not_to_contain_text("Collection (one ID or URL)")
# Single textarea + Add button.
expect(page.locator('textarea[name="input"]')).to_be_visible()
expect(page.locator('button:has-text("Add")')).to_be_visible()
# Refresh button is below the items table (DOM order = visual order here).
table = page.locator("#overlay-item-table")
refresh = page.locator('button:has-text("Refresh from Steam")')
expect(table).to_be_visible()
expect(refresh).to_be_visible()
table_y = table.bounding_box()["y"]
refresh_y = refresh.bounding_box()["y"]
assert table_y < refresh_y, "Refresh button should sit below the items table"
def test_workshop_autodetect_expands_collection_url(page: Page, workshop_overlay_server) -> None:
"""Pasting a collection URL into the textarea (no mode toggle) expands to
children server-side. Verified by patching the Steam helpers."""
base_url = workshop_overlay_server["base_url"]
overlay_id = workshop_overlay_server["overlay_id"]
login(page, base_url)
with patch.object(
steam_workshop, "expand_collections", return_value=["1001", "1002"]
), patch.object(
steam_workshop, "fetch_metadata_batch", return_value=[_meta("1001"), _meta("1002")]
):
page.goto(f"{base_url}/overlays/{overlay_id}")
page.locator('textarea[name="input"]').fill(
"https://steamcommunity.com/sharedfiles/filedetails/?id=555"
)
page.locator('button:has-text("Add")').click()
# Handler redirects to /jobs/<n>.
page.wait_for_url("**/jobs/*", timeout=5000)
A subtle gotcha: patch.object here patches the steam_workshop module in this test's process, but the live Flask server runs in a background thread of the same process (see _serve in conftest), so the patches will affect the live handler too. Confirm this still holds by examining _serve() — if it ever moves to a subprocess, these patches would stop working and the test would need a different injection strategy.
- Step 5.6: Run the new e2e tests
The e2e tests boot their own Flask server inside the test process via the live_server/workshop_overlay_server fixtures — no need for the dev server here.
cd /Users/mwiegand/Projekte/left4me/l4d2web && uv run pytest tests/e2e/test_workshop_section.py -v
Expected: PASS.
- Step 5.7: Manual visual smoke test
In the browser:
- Navigate to a workshop overlay's detail page.
- Confirm: single textarea labelled "Add items" with helper text mentioning autodetect; single "Add" button right-aligned below it.
- Items table renders. No internal footer bar inside the table border.
- Below the table: a row with summary on the left ("0 items" when empty; "{n} items · {size} total" when populated) and "↻ Refresh from Steam" button on the right.
- Refresh button is disabled (visually muted) when the table is empty.
- Paste a real collection URL (e.g.
https://steamcommunity.com/sharedfiles/filedetails/?id=3724125665), click Add. The 6 children of that collection should appear in the items table after the build job completes.
- Step 5.8: Commit
cd /Users/mwiegand/Projekte/left4me
git add l4d2web/l4d2web/templates/overlay_detail.html l4d2web/l4d2web/static/css/components.css l4d2web/tests/e2e/test_workshop_section.py l4d2web/tests/e2e/conftest.py l4d2web/l4d2web/routes/page_routes.py
git commit -m "$(cat <<'EOF'
style(overlays): redesign workshop items section
Drops the input-mode radio; the single textarea now accepts any mix of
items and collection URLs (backend autodetects in 0ffc3fd). 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.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 6 — Final verification + stale-content sweep
Files: (read-only sweep, no edits expected)
- Step 6.1: Stale-string sweep
cd /Users/mwiegand/Projekte/left4me && grep -rn "path is generated automatically" l4d2web/
Expected: zero matches. If any survive (e.g. in a translation file), remove them in a small follow-up commit.
cd /Users/mwiegand/Projekte/left4me && grep -rn "input_mode" l4d2web/l4d2web/templates/ l4d2web/l4d2web/routes/
Expected: zero matches. The form field is fully retired in both template and handler.
cd /Users/mwiegand/Projekte/left4me && grep -rn "workshop-input-mode\|overlay-type-radio\|workshop-refresh-form" l4d2web/
Expected: zero matches. These class names exist only in the old templates.
- Step 6.2: Full test suite
cd /Users/mwiegand/Projekte/left4me/l4d2web && uv run pytest tests/ -v --ignore=tests/e2e
Expected: all green.
cd /Users/mwiegand/Projekte/left4me/l4d2web && uv run pytest tests/e2e/ -v
Expected: all green (dev server must be running).
- Step 6.3: Visual end-to-end with the real Steam API
Optional but valuable: with the dev server running, create a brand-new workshop overlay through the UI, then paste this real-world mixed input into its workshop section:
3726529483
https://steamcommunity.com/sharedfiles/filedetails/?id=3724125665
(The first is a real L4D2 item; the second is a real L4D2 collection of 6 items, both verified live during brainstorming.)
After submitting:
-
Verify exactly 7 items end up in the table (1 single item + 6 collection children, deduplicated if any overlap).
-
Click "↻ Refresh from Steam" and verify the items refresh without error.
-
Step 6.4: No commit needed for this task — it's verification only. If any sweep surfaces stale strings, fix them in a tiny follow-up commit titled
chore(overlays): remove dead post-redesign referencesand re-run the sweep.
Out-of-scope follow-ups (do not include in this plan's commits)
.modal*→.dialog*rename across all six modals in the codebase.- General sweep for other native
<fieldset>/<input type="checkbox">/ native-radio markup that could adopt.radio-row/.switch-row(rename overlay modal, server-rename forms, etc.). - Adding
.superpowers/to a stricter ignore path or per-user gitignore (already gitignored in this repo).
These should each be their own change once this one is merged.