# 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 `` 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: ```python 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: ```bash 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): ```python 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: ```bash 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: ```bash 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** ```bash 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) 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: ```python 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: ```python 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: ```bash 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 36–99 (the entire `add_items` function) with: ```python @bp.post("/overlays//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: ```bash 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: ```bash 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: ```python 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: ```python 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: ```python data={"input": "1001", "input_mode": "items"}, ``` After: ```python data={"input": "1001"}, ``` Run the full file once more after the sweep: ```bash cd /Users/mwiegand/Projekte/left4me/l4d2web && uv run pytest tests/test_workshop_routes.py -v ``` Expected: all PASS. - [ ] **Step 2.7: Commit** ```bash 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) 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`: ```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: ```bash 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: balanced braces, chars`. - [ ] **Step 3.3: Commit** ```bash 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) 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 `` body** In `l4d2web/l4d2web/templates/overlays.html`, replace lines 29–54 with: ```jinja
``` Notes for the implementing engineer: - The hidden `csrf_token` input stays inside `