left4me/docs/superpowers/plans/2026-05-19-create-modal-and-workshop-section-redesign.md
mwiegand f1b0cbb5f1
docs(overlays): create-modal + workshop-section implementation plan
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>
2026-05-19 00:20:07 +02:00

48 KiB
Raw Blame History

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.py to 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_mode and 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_items handler

In l4d2web/l4d2web/routes/workshop_routes.py, replace lines 3699 (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.py to 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_mode field 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 2954 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">&times;</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_token input 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 still name.

  • The Type radios now use the .radio-row pattern. 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-row with the same label pattern. The form field name is still system_wide and 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:

  1. Field order is Name (input) → Type (three radios with descriptions) → System-wide (only if admin; switch + text).
  2. No bordered/fieldset box around Type.
  3. Radio dots are circular with a primary-color filled inner dot when selected; clicking changes selection.
  4. Switch animates left↔right when toggled.
  5. No "The path is generated automatically." paragraph.
  6. 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 4367 (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&#10;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-input class for the monospace font tweak (added in Step 5.2 below).

  • The placeholder shows 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_count and workshop_items_total_size. The route handler that renders overlay_detail.html will 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-input mono-font rule to components.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_count and workshop_items_total_size into 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_server fixture 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:

  1. Navigate to a workshop overlay's detail page.
  2. Confirm: single textarea labelled "Add items" with helper text mentioning autodetect; single "Add" button right-aligned below it.
  3. Items table renders. No internal footer bar inside the table border.
  4. 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.
  5. Refresh button is disabled (visually muted) when the table is empty.
  6. 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 references and 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.