diff --git a/docs/superpowers/plans/2026-05-19-create-modal-and-workshop-section-redesign.md b/docs/superpowers/plans/2026-05-19-create-modal-and-workshop-section-redesign.md new file mode 100644 index 0000000..8faaff0 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-create-modal-and-workshop-section-redesign.md @@ -0,0 +1,1223 @@ +# 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 `