# L4D2 Web UI 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:** Implement the approved functional admin-console UI for `l4d2web` without changing the Flask/Jinja/HTMX/SSE architecture. **Architecture:** Treat the current `l4d2web` implementation as a rough draft, not a compatibility boundary. Keep the Flask/Jinja/HTMX/SSE architecture, but replace routes, templates, CSS, JavaScript, and tests where needed to match the approved UI contract. Use tokenized CSS for neutral light/dark themes, standard HTML forms for CRUD and lifecycle actions, HTMX only for small partial updates, and SSE for log streams. **Tech Stack:** Python 3.12+, Flask, SQLAlchemy, pytest, Jinja templates, vendored HTMX, vanilla JavaScript, custom CSS, SSE. --- ## File Structure - `l4d2web/routes/page_routes.py`: GET pages for dashboard, servers, blueprints, overlays, server detail, blueprint detail, admin, users, and jobs. - `l4d2web/routes/overlay_routes.py`: admin-only overlay create, update, and delete form handlers under `/overlays`. - `l4d2web/routes/blueprint_routes.py`: standard form create/update/delete handlers for blueprints and ordered overlays. Keep JSON handling only if it remains useful and does not constrain the UI. - `l4d2web/routes/server_routes.py`: standard form lifecycle action handlers that enqueue `Job` rows. Keep JSON handling only if it remains useful and does not constrain the UI. - `l4d2web/templates/base.html`: shared shell, nav, account area, logout POST form, CSS/JS includes. - `l4d2web/templates/dashboard.html`: simple landing text. - `l4d2web/templates/servers.html`: server list table. - `l4d2web/templates/server_detail.html`: server operations, metadata, blueprint summary, job log, and server log. - `l4d2web/templates/blueprints.html`: blueprint list table and create form. - `l4d2web/templates/blueprint_detail.html`: blueprint edit form with ordered overlays and textareas. - `l4d2web/templates/overlays.html`: single overlay catalog page with admin-only edit controls. - `l4d2web/templates/admin.html`: admin landing page. - `l4d2web/templates/admin_users.html`: admin user list. - `l4d2web/templates/admin_jobs.html`: admin job list. - `l4d2web/static/css/tokens.css`: all color, spacing, line, radius, and light/dark variables. - `l4d2web/static/css/layout.css`: page shell and layout primitives using tokens. - `l4d2web/static/css/components.css`: tables, forms, buttons, status badges, and panels using tokens. - `l4d2web/static/css/logs.css`: tokenized log panels. - `l4d2web/static/js/sse.js`: unobtrusive SSE binding for log elements with `data-sse-url`. - `l4d2web/tests/test_pages.py`: page, shell, nav, server, blueprint, and admin page tests. - `l4d2web/tests/test_overlays.py`: overlay page and admin form tests. - `l4d2web/tests/test_blueprints.py`: form-based blueprint creation/editing tests plus any retained JSON behavior. - `l4d2web/tests/test_servers.py`: server form and lifecycle enqueue tests plus any retained JSON behavior. - `AGENTS.md`: remove the stale fixed link-color contract. - `docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md`: remove the stale fixed link-color contract. The current web code and tests may be replaced instead of carefully preserved. When this plan says to append a test or modify a draft file, an implementer may rewrite the file if that is clearer and still satisfies the task tests and final suite. ### Task 1: Implement shell, tokens, and dashboard landing text **Files:** - Modify: `l4d2web/templates/base.html` - Modify: `l4d2web/templates/dashboard.html` - Modify: `l4d2web/static/css/tokens.css` - Modify: `l4d2web/static/css/layout.css` - Modify: `l4d2web/static/css/components.css` - Modify: `l4d2web/static/css/logs.css` - Test: `l4d2web/tests/test_pages.py` - [ ] **Step 1: Add failing shell and dashboard tests** Append these tests to `l4d2web/tests/test_pages.py`: ```python def test_dashboard_is_simple_landing_page(auth_client_with_server) -> None: response = auth_client_with_server.get("/dashboard") text = response.get_data(as_text=True) assert response.status_code == 200 assert "Dashboard" in text assert "Use the navigation to manage servers, blueprints, and overlays." in text assert "alpha" not in text def test_shell_nav_uses_main_sections(auth_client_with_server) -> None: response = auth_client_with_server.get("/dashboard") text = response.get_data(as_text=True) assert 'href="/dashboard"' in text assert 'href="/servers"' in text assert 'href="/blueprints"' in text assert 'href="/overlays"' in text assert 'action="/logout"' in text assert "csrf_token" in text def test_css_tokens_define_neutral_light_and_dark_theme() -> None: css = Path("l4d2web/static/css/tokens.css").read_text() for token in [ "--color-bg", "--color-surface", "--color-text", "--color-muted", "--color-border", "--color-link", "--space-base", "--space-xs", "--space-s", "--space-m", "--space-l", "--space-xl", "--space-2xl", "--radius-s", "--radius-m", "--line", ]: assert token in css assert "prefers-color-scheme: dark" in css assert "radial-gradient" not in Path("l4d2web/static/css/layout.css").read_text() ``` Also add the import at the top of `l4d2web/tests/test_pages.py`: ```python from pathlib import Path ``` - [ ] **Step 2: Run tests and verify failure** Run: `pytest l4d2web/tests/test_pages.py::test_dashboard_is_simple_landing_page l4d2web/tests/test_pages.py::test_shell_nav_uses_main_sections l4d2web/tests/test_pages.py::test_css_tokens_define_neutral_light_and_dark_theme -q` Expected: FAIL because the dashboard still renders servers and the CSS tokens are not the approved neutral theme. - [ ] **Step 3: Update base shell and dashboard** Change `l4d2web/templates/base.html` to use this shell shape: ```html {% block title %}left4me{% endblock %}
{% block content %}{% endblock %}
``` Change `l4d2web/templates/dashboard.html` to: ```html {% extends "base.html" %} {% block title %}Dashboard | left4me{% endblock %} {% block content %}

Dashboard

Use the navigation to manage servers, blueprints, and overlays.

{% endblock %} ``` - [ ] **Step 4: Update CSS tokens and layout** Replace `l4d2web/static/css/tokens.css` with: ```css :root { --color-bg: #f4f4f5; --color-surface: #ffffff; --color-surface-muted: #f8fafc; --color-text: #18181b; --color-muted: #60646c; --color-border: #d4d4d8; --color-link: #1d4ed8; --color-primary: #1d4ed8; --color-danger: #b42318; --color-warning: #a15c07; --color-success: #067647; --color-focus: #2563eb; --color-log-bg: #111827; --color-log-text: #e5e7eb; --space-base: 0.25rem; --space-xs: var(--space-base); --space-s: calc(var(--space-base) * 2); --space-m: calc(var(--space-base) * 3); --space-l: calc(var(--space-base) * 4); --space-xl: calc(var(--space-base) * 6); --space-2xl: calc(var(--space-base) * 8); --radius-base: 0.25rem; --radius-s: var(--radius-base); --radius-m: calc(var(--radius-base) * 2); --line: 1px solid var(--color-border); } @media (prefers-color-scheme: dark) { :root { --color-bg: #18181b; --color-surface: #27272a; --color-surface-muted: #1f1f23; --color-text: #f4f4f5; --color-muted: #a1a1aa; --color-border: #3f3f46; --color-link: #93c5fd; --color-primary: #93c5fd; --color-danger: #fca5a5; --color-warning: #fcd34d; --color-success: #86efac; --color-focus: #bfdbfe; } } a { color: var(--color-link); } ``` Update `layout.css`, `components.css`, and `logs.css` so all colors, spacing, borders, and radii reference tokens. Use `.panel` as the new neutral surface class and keep `.card` as an alias during migration: ```css .panel, .card { background: var(--color-surface); border: var(--line); border-radius: var(--radius-m); padding: var(--space-l); margin-bottom: var(--space-l); } ``` - [ ] **Step 5: Run tests and verify pass** Run: `pytest l4d2web/tests/test_pages.py::test_dashboard_is_simple_landing_page l4d2web/tests/test_pages.py::test_shell_nav_uses_main_sections l4d2web/tests/test_pages.py::test_css_tokens_define_neutral_light_and_dark_theme -q` Expected: PASS. - [ ] **Step 6: Commit shell and token changes** ```bash git add l4d2web/templates/base.html l4d2web/templates/dashboard.html l4d2web/static/css/tokens.css l4d2web/static/css/layout.css l4d2web/static/css/components.css l4d2web/static/css/logs.css l4d2web/tests/test_pages.py git commit -m "feat(l4d2-web): add neutral shell and theme tokens" ``` ### Task 2: Consolidate overlays into one authenticated page **Files:** - Modify: `l4d2web/routes/page_routes.py` - Modify: `l4d2web/routes/overlay_routes.py` - Create: `l4d2web/templates/overlays.html` - Delete: `l4d2web/templates/admin_overlays.html` - Test: `l4d2web/tests/test_overlays.py` - [ ] **Step 1: Replace overlay tests with the single-page contract** Update `l4d2web/tests/test_overlays.py` so it includes these tests: ```python from l4d2web.models import Overlay, User def test_user_can_view_overlay_catalog(user_client_with_overlay) -> None: response = user_client_with_overlay.get("/overlays") text = response.get_data(as_text=True) assert response.status_code == 200 assert "standard" in text assert "Add overlay" not in text def test_admin_can_view_overlay_edit_controls(admin_client) -> None: response = admin_client.get("/overlays") text = response.get_data(as_text=True) assert response.status_code == 200 assert "Add overlay" in text assert 'action="/overlays"' in text def test_admin_can_create_overlay(admin_client) -> None: response = admin_client.post( "/overlays", data={"name": "standard", "path": "/opt/l4d2/overlays/standard"}, headers={"X-CSRF-Token": "test-token"}, ) assert response.status_code == 302 assert response.headers["Location"] == "/overlays" def test_overlay_path_must_be_under_root(admin_client) -> None: response = admin_client.post( "/overlays", data={"name": "bad", "path": "/tmp/bad"}, headers={"X-CSRF-Token": "test-token"}, ) assert response.status_code == 400 def test_non_admin_cannot_create_overlay(user_client_with_overlay) -> None: response = user_client_with_overlay.post( "/overlays", data={"name": "bad", "path": "/opt/l4d2/overlays/bad"}, headers={"X-CSRF-Token": "test-token"}, ) assert response.status_code == 403 def test_admin_can_update_and_delete_overlay(admin_client) -> None: create = admin_client.post( "/overlays", data={"name": "standard", "path": "/opt/l4d2/overlays/standard"}, headers={"X-CSRF-Token": "test-token"}, ) assert create.status_code == 302 update = admin_client.post( "/overlays/1", data={"name": "edited", "path": "/opt/l4d2/overlays/edited"}, headers={"X-CSRF-Token": "test-token"}, ) assert update.status_code == 302 delete = admin_client.post( "/overlays/1/delete", headers={"X-CSRF-Token": "test-token"}, ) assert delete.status_code == 302 ``` Add a `user_client_with_overlay` fixture that creates a non-admin user and one overlay, sets `user_id`, and sets `csrf_token` to `test-token`. - [ ] **Step 2: Run tests and verify failure** Run: `pytest l4d2web/tests/test_overlays.py -q` Expected: FAIL because the app still uses `/admin/overlays` and has no GET `/overlays` page. - [ ] **Step 3: Move overlay form handlers to `/overlays`** In `l4d2web/routes/overlay_routes.py`, replace the current create route and add update/delete routes: ```python @bp.post("/overlays") @require_admin def create_overlay() -> Response: name = request.form.get("name", "").strip() raw_path = request.form.get("path", "").strip() if not name or not raw_path: return Response("missing fields", status=400) try: validated_path = validate_overlay_path(raw_path) except ValueError as exc: return Response(str(exc), status=400) with session_scope() as db: existing = db.scalar(select(Overlay).where(Overlay.name == name)) if existing is not None: return Response("overlay already exists", status=409) db.add(Overlay(name=name, path=str(validated_path))) return redirect("/overlays") @bp.post("/overlays/") @require_admin def update_overlay(overlay_id: int) -> Response: name = request.form.get("name", "").strip() raw_path = request.form.get("path", "").strip() if not name or not raw_path: return Response("missing fields", status=400) try: validated_path = validate_overlay_path(raw_path) except ValueError as exc: return Response(str(exc), status=400) with session_scope() as db: overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id)) if overlay is None: return Response(status=404) overlay.name = name overlay.path = str(validated_path) return redirect("/overlays") @bp.post("/overlays//delete") @require_admin def delete_overlay(overlay_id: int) -> Response: with session_scope() as db: overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id)) if overlay is None: return Response(status=404) db.delete(overlay) return redirect("/overlays") ``` - [ ] **Step 4: Add the `/overlays` page and remove `/admin/overlays`** In `l4d2web/routes/page_routes.py`, replace the `admin_overlays` route with: ```python @bp.get("/overlays") @require_login def overlays() -> str: with session_scope() as db: overlays = db.scalars(select(Overlay).order_by(Overlay.name)).all() return render_template("overlays.html", overlays=overlays) ``` Create `l4d2web/templates/overlays.html`: ```html {% extends "base.html" %} {% block title %}Overlays | left4me{% endblock %} {% block content %}

Overlays

{% if g.user.admin %}
{% endif %} {% if g.user.admin %}{% endif %} {% for overlay in overlays %} {% if g.user.admin %} {% endif %} {% else %} {% endfor %}
NamePathActions
{{ overlay.name }} {{ overlay.path }}
No overlays configured.
{% endblock %} ``` Delete `l4d2web/templates/admin_overlays.html` after the new template is in place. - [ ] **Step 5: Run tests and verify pass** Run: `pytest l4d2web/tests/test_overlays.py -q` Expected: PASS. - [ ] **Step 6: Commit overlay consolidation** ```bash git add l4d2web/routes/page_routes.py l4d2web/routes/overlay_routes.py l4d2web/templates/overlays.html l4d2web/tests/test_overlays.py git rm l4d2web/templates/admin_overlays.html git commit -m "feat(l4d2-web): consolidate overlay catalog page" ``` ### Task 3: Add server list, server detail, and lifecycle form enqueueing **Files:** - Modify: `l4d2web/routes/page_routes.py` - Modify: `l4d2web/routes/server_routes.py` - Create: `l4d2web/templates/servers.html` - Modify: `l4d2web/templates/server_detail.html` - Modify: `l4d2web/static/js/sse.js` - Test: `l4d2web/tests/test_pages.py` - Test: `l4d2web/tests/test_servers.py` - [ ] **Step 1: Add failing server page tests** Append these tests to `l4d2web/tests/test_pages.py`: ```python def test_servers_page_links_server_names(auth_client_with_server) -> None: response = auth_client_with_server.get("/servers") text = response.get_data(as_text=True) assert response.status_code == 200 assert 'alpha' in text assert "View" not in text assert ">details<" not in text def test_server_detail_shows_operations_and_logs(auth_client_with_server) -> None: response = auth_client_with_server.get("/servers/1") text = response.get_data(as_text=True) assert response.status_code == 200 assert "Server: alpha" in text assert 'action="/servers/1/start"' in text assert 'action="/servers/1/stop"' in text assert 'action="/servers/1/initialize"' in text assert 'action="/servers/1/delete"' in text assert 'href="/blueprints/1"' in text assert 'data-sse-url="/servers/1/logs/stream"' in text ``` Append this test to `l4d2web/tests/test_servers.py`: ```python def test_lifecycle_form_creates_queued_job(user_client_with_blueprints) -> None: client, data = user_client_with_blueprints create_response = client.post( "/servers", data=json.dumps({"name": "alpha", "port": 27015, "blueprint_id": data["blueprint_id"]}), content_type="application/json", headers={"X-CSRF-Token": "test-token"}, ) server_id = create_response.get_json()["id"] response = client.post( f"/servers/{server_id}/start", headers={"X-CSRF-Token": "test-token"}, ) assert response.status_code == 302 assert response.headers["Location"] == f"/servers/{server_id}" ``` - [ ] **Step 2: Run tests and verify failure** Run: `pytest l4d2web/tests/test_pages.py::test_servers_page_links_server_names l4d2web/tests/test_pages.py::test_server_detail_shows_operations_and_logs l4d2web/tests/test_servers.py::test_lifecycle_form_creates_queued_job -q` Expected: FAIL because `/servers` has no GET page and lifecycle form routes do not exist. - [ ] **Step 3: Add lifecycle form endpoint** In `l4d2web/routes/server_routes.py`, import `redirect` and `Job`, then add: ```python LIFECYCLE_OPERATIONS = {"initialize", "start", "stop", "delete"} @bp.post("/servers//") @require_login def enqueue_server_operation(server_id: int, operation: str) -> Response: user = current_user() assert user is not None if operation not in LIFECYCLE_OPERATIONS: return Response(status=404) with session_scope() as db: server = db.scalar(select(Server).where(Server.id == server_id, Server.user_id == user.id)) if server is None: return Response(status=404) db.add(Job(user_id=user.id, server_id=server.id, operation=operation, state="queued")) if operation == "start": server.desired_state = "running" if operation in {"stop", "delete"}: server.desired_state = "stopped" return redirect(f"/servers/{server_id}") ``` - [ ] **Step 4: Add server list and enrich server detail route** In `l4d2web/routes/page_routes.py`, add imports for `Job` and `BlueprintOverlay`, then add: ```python @bp.get("/servers") @require_login def servers_page() -> str: user = current_user() assert user is not None with session_scope() as db: rows = db.execute( select(Server, BlueprintModel) .join(BlueprintModel, BlueprintModel.id == Server.blueprint_id) .where(Server.user_id == user.id) .order_by(Server.name) ).all() return render_template("servers.html", rows=rows) ``` Replace `server_detail` with a version that loads server, blueprint, ordered overlay names, decoded arguments/config, and the newest job: ```python @bp.get("/servers/") @require_login def server_detail(server_id: int): user = current_user() assert user is not None with session_scope() as db: server = db.scalar(select(Server).where(Server.id == server_id, Server.user_id == user.id)) if server is None: return Response(status=404) blueprint = db.scalar(select(BlueprintModel).where(BlueprintModel.id == server.blueprint_id)) overlay_rows = db.execute( select(Overlay.name) .join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id) .where(BlueprintOverlay.blueprint_id == server.blueprint_id) .order_by(BlueprintOverlay.position) ).all() latest_job = db.scalar( select(Job) .where(Job.server_id == server.id) .order_by(Job.created_at.desc()) .limit(1) ) return render_template( "server_detail.html", server=server, blueprint=blueprint, overlay_names=[row[0] for row in overlay_rows], arguments=json.loads(blueprint.arguments) if blueprint is not None else [], config_lines=json.loads(blueprint.config) if blueprint is not None else [], latest_job=latest_job, ) ``` - [ ] **Step 5: Add server templates and generic SSE binding** Create `l4d2web/templates/servers.html`: ```html {% extends "base.html" %} {% block title %}Servers | left4me{% endblock %} {% block content %}

Servers

{% for server, blueprint in rows %} {% else %} {% endfor %}
NamePortBlueprintDesiredActual
{{ server.name }} {{ server.port }} {{ blueprint.name }} {{ server.desired_state }} {{ server.actual_state }}
No servers configured.
{% endblock %} ``` Replace `l4d2web/templates/server_detail.html` with: ```html {% extends "base.html" %} {% block title %}Server {{ server.name }} | left4me{% endblock %} {% block content %}

Server: {{ server.name }}

{% for operation in ["initialize", "start", "stop"] %}
{% endfor %}
Name{{ server.name }}
Port{{ server.port }}
Blueprint{% if blueprint %}{{ blueprint.name }}{% endif %}
Desired state{{ server.desired_state }}
Actual state{{ server.actual_state }}
Last error{{ server.last_error or "-" }}

Blueprint

Overlay order

    {% for name in overlay_names %}
  1. {{ name }}
  2. {% else %}
  3. No overlays configured.
  4. {% endfor %}

Arguments

{{ arguments | join('\n') }}

Config

{{ config_lines | join('\n') }}

Current / Recent Job

{% if latest_job %}
Operation{{ latest_job.operation }}
State{{ latest_job.state }}

  {% else %}
  

No jobs have run for this server.

{% endif %}

Server Log


{% endblock %} ``` Update `l4d2web/static/js/sse.js` so it binds every element with `data-sse-url`: ```javascript function streamTextToElement(element) { const url = element.dataset.sseUrl; if (!url) { return; } const source = new EventSource(url); source.onmessage = (event) => { element.textContent += `${event.data}\n`; element.scrollTop = element.scrollHeight; }; } document.addEventListener("DOMContentLoaded", () => { document.querySelectorAll("[data-sse-url]").forEach(streamTextToElement); }); ``` - [ ] **Step 6: Run tests and verify pass** Run: `pytest l4d2web/tests/test_pages.py::test_servers_page_links_server_names l4d2web/tests/test_pages.py::test_server_detail_shows_operations_and_logs l4d2web/tests/test_servers.py::test_lifecycle_form_creates_queued_job -q` Expected: PASS. - [ ] **Step 7: Commit server pages** ```bash git add l4d2web/routes/page_routes.py l4d2web/routes/server_routes.py l4d2web/templates/servers.html l4d2web/templates/server_detail.html l4d2web/static/js/sse.js l4d2web/tests/test_pages.py l4d2web/tests/test_servers.py git commit -m "feat(l4d2-web): add server pages and lifecycle forms" ``` ### Task 4: Add blueprint list and form-based ordered overlay editing **Files:** - Modify: `l4d2web/routes/page_routes.py` - Modify: `l4d2web/routes/blueprint_routes.py` - Modify: `l4d2web/templates/blueprints.html` - Create: `l4d2web/templates/blueprint_detail.html` - Test: `l4d2web/tests/test_pages.py` - Test: `l4d2web/tests/test_blueprints.py` - [ ] **Step 1: Add failing blueprint page and form tests** Append these tests to `l4d2web/tests/test_pages.py`: ```python def test_blueprint_pages_fixture_has_ordered_overlay_data(auth_client_with_server) -> None: response = auth_client_with_server.get("/blueprints/1") text = response.get_data(as_text=True) assert response.status_code == 200 assert "standard" in text def test_blueprints_page_links_blueprint_names(auth_client_with_server) -> None: response = auth_client_with_server.get("/blueprints") text = response.get_data(as_text=True) assert response.status_code == 200 assert 'default' in text assert "View" not in text def test_blueprint_detail_has_ordered_overlay_form(auth_client_with_server) -> None: response = auth_client_with_server.get("/blueprints/1") text = response.get_data(as_text=True) assert response.status_code == 200 assert "Overlay order matters" in text assert 'name="arguments"' in text assert 'name="config"' in text assert 'name="overlay_ids"' in text assert 'name="overlay_position_1"' in text ``` Update the `auth_client_with_server` fixture in `l4d2web/tests/test_pages.py` so it imports `BlueprintOverlay` and `Overlay`, creates one overlay named `standard`, and links it to blueprint `default` with position `0`. Append this test to `l4d2web/tests/test_blueprints.py`: ```python def test_form_update_preserves_ordered_overlays_and_multiline_fields(user_client) -> None: create = user_client.post( "/blueprints", data={ "name": "comp", "arguments": "-tickrate 100", "config": "sv_consistency 1", "overlay_ids": ["2", "1"], "overlay_position_1": "2", "overlay_position_2": "1", }, headers={"X-CSRF-Token": "test-token"}, ) assert create.status_code == 302 update = user_client.post( "/blueprints/1", data={ "name": "edited", "arguments": "-tickrate 100\n+sv_lan 0", "config": "sv_consistency 1\nsv_allow_lobby_connect_only 0", "overlay_ids": ["1", "2"], "overlay_position_1": "1", "overlay_position_2": "2", }, headers={"X-CSRF-Token": "test-token"}, ) assert update.status_code == 302 assert update.headers["Location"] == "/blueprints/1" ``` - [ ] **Step 2: Run tests and verify failure** Run: `pytest l4d2web/tests/test_pages.py::test_blueprint_pages_fixture_has_ordered_overlay_data l4d2web/tests/test_pages.py::test_blueprints_page_links_blueprint_names l4d2web/tests/test_pages.py::test_blueprint_detail_has_ordered_overlay_form l4d2web/tests/test_blueprints.py::test_form_update_preserves_ordered_overlays_and_multiline_fields -q` Expected: FAIL because `/blueprints` has no GET page and the blueprint routes accept JSON only. - [ ] **Step 3: Add form helpers to blueprint routes** In `l4d2web/routes/blueprint_routes.py`, add helpers: ```python def split_textarea_lines(raw: str) -> list[str]: return [line.strip() for line in raw.splitlines() if line.strip()] def ordered_overlay_ids_from_form() -> list[int]: ordered = [] for fallback_position, value in enumerate(request.form.getlist("overlay_ids")): if not value: continue overlay_id = int(value) raw_position = request.form.get(f"overlay_position_{overlay_id}", "").strip() try: position = int(raw_position) except ValueError: position = fallback_position + 1 ordered.append((position, fallback_position, overlay_id)) return [overlay_id for _, _, overlay_id in sorted(ordered)] def replace_blueprint_overlays(db, blueprint_id: int, overlay_ids: list[int]) -> None: db.execute(delete(BlueprintOverlay).where(BlueprintOverlay.blueprint_id == blueprint_id)) for position, overlay_id in enumerate(overlay_ids): db.add(BlueprintOverlay(blueprint_id=blueprint_id, overlay_id=overlay_id, position=position)) ``` - [ ] **Step 4: Support form creates and retain JSON only if useful** Change `create_blueprint` so form requests redirect to the new detail page. The implementation below also keeps JSON requests returning `201` because the draft tests already cover that path, but JSON support is not a UI compatibility requirement: ```python @bp.post("/blueprints") @require_login def create_blueprint() -> Response: user = current_user() assert user is not None if request.is_json: payload = request.get_json(silent=True) or {} name = str(payload.get("name", "")).strip() arguments = [str(item) for item in payload.get("arguments", [])] config = [str(item) for item in payload.get("config", [])] overlay_ids = [int(item) for item in payload.get("overlay_ids", [])] json_response = True else: name = request.form.get("name", "").strip() arguments = split_textarea_lines(request.form.get("arguments", "")) config = split_textarea_lines(request.form.get("config", "")) overlay_ids = ordered_overlay_ids_from_form() json_response = False if not name: return Response("name is required", status=400) with session_scope() as db: blueprint = BlueprintModel(user_id=user.id, name=name, arguments=json.dumps(arguments), config=json.dumps(config)) db.add(blueprint) db.flush() replace_blueprint_overlays(db, blueprint.id, overlay_ids) blueprint_id = blueprint.id if json_response: return jsonify({"id": blueprint_id}), 201 return redirect(f"/blueprints/{blueprint_id}") ``` - [ ] **Step 5: Add form update route for blueprint details** Add this route to `l4d2web/routes/blueprint_routes.py`: ```python @bp.post("/blueprints/") @require_login def update_blueprint_form(blueprint_id: int) -> Response: user = current_user() assert user is not None name = request.form.get("name", "").strip() if not name: return Response("name is required", status=400) with session_scope() as db: blueprint = db.scalar( select(BlueprintModel).where(BlueprintModel.id == blueprint_id, BlueprintModel.user_id == user.id) ) if blueprint is None: return Response(status=404) blueprint.name = name blueprint.arguments = json.dumps(split_textarea_lines(request.form.get("arguments", ""))) blueprint.config = json.dumps(split_textarea_lines(request.form.get("config", ""))) replace_blueprint_overlays(db, blueprint.id, ordered_overlay_ids_from_form()) return redirect(f"/blueprints/{blueprint_id}") ``` - [ ] **Step 6: Add blueprint list route and detail data** In `l4d2web/routes/page_routes.py`, add a `/blueprints` route: ```python @bp.get("/blueprints") @require_login def blueprints_page() -> str: user = current_user() assert user is not None with session_scope() as db: blueprints = db.scalars( select(BlueprintModel).where(BlueprintModel.user_id == user.id).order_by(BlueprintModel.name) ).all() return render_template("blueprints.html", blueprints=blueprints) ``` Replace the existing `blueprint_page` render logic with: ```python @bp.get("/blueprints/") @require_login def blueprint_page(blueprint_id: int): user = current_user() assert user is not None with session_scope() as db: blueprint = db.scalar(select(BlueprintModel).where(BlueprintModel.id == blueprint_id)) if blueprint is None: return Response(status=404) if blueprint.user_id != user.id: return Response(status=403) selected_overlays = db.scalars( select(Overlay) .join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id) .where(BlueprintOverlay.blueprint_id == blueprint.id) .order_by(BlueprintOverlay.position) ).all() position_rows = db.execute( select(BlueprintOverlay.overlay_id, BlueprintOverlay.position) .where(BlueprintOverlay.blueprint_id == blueprint.id) ).all() all_overlays = db.scalars(select(Overlay).order_by(Overlay.name)).all() overlay_positions = {overlay_id: position + 1 for overlay_id, position in position_rows} return render_template( "blueprint_detail.html", blueprint=blueprint, selected_overlays=selected_overlays, all_overlays=all_overlays, selected_overlay_ids={overlay.id for overlay in selected_overlays}, overlay_positions=overlay_positions, arguments=json.loads(blueprint.arguments), config_lines=json.loads(blueprint.config), ) ``` - [ ] **Step 7: Create blueprint templates** Replace `l4d2web/templates/blueprints.html` with: ```html {% extends "base.html" %} {% block title %}Blueprints | left4me{% endblock %} {% block content %}

Blueprints

{% for blueprint in blueprints %} {% else %} {% endfor %}
NameCreatedUpdatedActions
{{ blueprint.name }} {{ blueprint.created_at }} {{ blueprint.updated_at }}
No blueprints configured.
{% endblock %} ``` Create `l4d2web/templates/blueprint_detail.html` with: ```html {% extends "base.html" %} {% block title %}Blueprint {{ blueprint.name }} | left4me{% endblock %} {% block content %}

Blueprint: {{ blueprint.name }}

Overlay order matters: the first overlay has highest precedence.

{% for overlay in all_overlays %} {% else %} {% endfor %}
UseOrderOverlay
{{ overlay.name }}
No overlays available.
{% endblock %} ``` - [ ] **Step 8: Run tests and verify pass** Run: `pytest l4d2web/tests/test_pages.py::test_blueprint_pages_fixture_has_ordered_overlay_data l4d2web/tests/test_pages.py::test_blueprints_page_links_blueprint_names l4d2web/tests/test_pages.py::test_blueprint_detail_has_ordered_overlay_form l4d2web/tests/test_blueprints.py::test_form_update_preserves_ordered_overlays_and_multiline_fields -q` Expected: PASS. - [ ] **Step 9: Commit blueprint pages** ```bash git add l4d2web/routes/page_routes.py l4d2web/routes/blueprint_routes.py l4d2web/templates/blueprints.html l4d2web/templates/blueprint_detail.html l4d2web/tests/test_pages.py l4d2web/tests/test_blueprints.py git commit -m "feat(l4d2-web): add form-based blueprint editor" ``` ### Task 5: Add admin landing, users, and jobs pages **Files:** - Modify: `l4d2web/routes/page_routes.py` - Create: `l4d2web/templates/admin.html` - Create: `l4d2web/templates/admin_users.html` - Create: `l4d2web/templates/admin_jobs.html` - Test: `l4d2web/tests/test_pages.py` - [ ] **Step 1: Add failing admin page tests** Append these tests to `l4d2web/tests/test_pages.py`: ```python def test_non_admin_does_not_see_admin_nav(auth_client_with_server) -> None: response = auth_client_with_server.get("/dashboard") text = response.get_data(as_text=True) assert response.status_code == 200 assert 'href="/admin"' not in text def test_admin_can_use_admin_pages(tmp_path, monkeypatch) -> None: db_url = f"sqlite:///{tmp_path/'admin-pages.db'}" monkeypatch.setenv("DATABASE_URL", db_url) app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"}) init_db() with session_scope() as session: admin = User(username="admin", password_digest=hash_password("secret"), admin=True) session.add(admin) session.flush() admin_id = admin.id client = app.test_client() with client.session_transaction() as sess: sess["user_id"] = admin_id assert client.get("/admin").status_code == 200 assert client.get("/admin/users").status_code == 200 assert client.get("/admin/jobs").status_code == 200 assert 'href="/admin"' in client.get("/dashboard").get_data(as_text=True) def test_non_admin_cannot_open_admin_pages(auth_client_with_server) -> None: assert auth_client_with_server.get("/admin").status_code == 403 assert auth_client_with_server.get("/admin/users").status_code == 403 assert auth_client_with_server.get("/admin/jobs").status_code == 403 ``` - [ ] **Step 2: Run tests and verify failure** Run: `pytest l4d2web/tests/test_pages.py::test_non_admin_does_not_see_admin_nav l4d2web/tests/test_pages.py::test_admin_can_use_admin_pages l4d2web/tests/test_pages.py::test_non_admin_cannot_open_admin_pages -q` Expected: FAIL because the admin pages do not exist. - [ ] **Step 3: Add admin page routes** In `l4d2web/routes/page_routes.py`, add imports for `User` and `Job`, then add: ```python @bp.get("/admin") @require_admin def admin_home() -> str: return render_template("admin.html") @bp.get("/admin/users") @require_admin def admin_users() -> str: with session_scope() as db: users = db.scalars(select(User).order_by(User.username)).all() return render_template("admin_users.html", users=users) @bp.get("/admin/jobs") @require_admin def admin_jobs() -> str: with session_scope() as db: rows = db.execute( select(Job, User, Server) .join(User, User.id == Job.user_id) .outerjoin(Server, Server.id == Job.server_id) .order_by(Job.created_at.desc()) ).all() return render_template("admin_jobs.html", rows=rows) ``` - [ ] **Step 4: Add admin templates** Create `l4d2web/templates/admin.html`: ```html {% extends "base.html" %} {% block title %}Admin | left4me{% endblock %} {% block content %}

Admin

{% endblock %} ``` Create `l4d2web/templates/admin_users.html`: ```html {% extends "base.html" %} {% block title %}Admin Users | left4me{% endblock %} {% block content %}

Users

{% for user in users %} {% else %} {% endfor %}
UsernameAdminCreatedUpdated
{{ user.username }}{{ "yes" if user.admin else "no" }}{{ user.created_at }}{{ user.updated_at }}
No users found.
{% endblock %} ``` Create `l4d2web/templates/admin_jobs.html`: ```html {% extends "base.html" %} {% block title %}Admin Jobs | left4me{% endblock %} {% block content %}

Jobs

{% for job, user, server in rows %} {% else %} {% endfor %}
IDOperationStateUserServerCreatedFinished
{{ job.id }} {{ job.operation }} {{ job.state }} {{ user.username }} {% if server %}{{ server.name }}{% else %}-{% endif %} {{ job.created_at }} {{ job.finished_at or "-" }}
No jobs found.
{% endblock %} ``` - [ ] **Step 5: Run tests and verify pass** Run: `pytest l4d2web/tests/test_pages.py::test_non_admin_does_not_see_admin_nav l4d2web/tests/test_pages.py::test_admin_can_use_admin_pages l4d2web/tests/test_pages.py::test_non_admin_cannot_open_admin_pages -q` Expected: PASS. - [ ] **Step 6: Commit admin pages** ```bash git add l4d2web/routes/page_routes.py l4d2web/templates/admin.html l4d2web/templates/admin_users.html l4d2web/templates/admin_jobs.html l4d2web/tests/test_pages.py git commit -m "feat(l4d2-web): add admin landing and system pages" ``` ### Task 6: Update stale color-contract docs and run full web test suite **Files:** - Modify: `AGENTS.md` - Modify: `docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md` - Test: `l4d2web/tests/*.py` - [ ] **Step 1: Update stale fixed-link-color wording** In `AGENTS.md`, replace: ```markdown - Custom CSS with consistent link color `#0F766E`. ``` with: ```markdown - Custom CSS with tokenized, consistent link and accent colors. ``` In `docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md`, replace: ```markdown - consistent link color `#0F766E` ``` with: ```markdown - tokenized, consistent link and accent colors ``` Also update the sample CSS token in that plan from `--color-link: #0F766E;` to: ```css --color-link: var(--color-primary); ``` - [ ] **Step 2: Verify stale exact color contract is gone from active guidance** Run: `rg '#0F766E|0F766E' AGENTS.md docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md l4d2web/static/css` Expected: no matches. - [ ] **Step 3: Run the focused UI test files** Run: `pytest l4d2web/tests/test_pages.py l4d2web/tests/test_overlays.py l4d2web/tests/test_blueprints.py l4d2web/tests/test_servers.py -q` Expected: PASS. - [ ] **Step 4: Run the full web test suite** Run: `pytest l4d2web/tests -q` Expected: PASS. - [ ] **Step 5: Commit docs and final verification updates** ```bash git add AGENTS.md docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md git commit -m "docs(l4d2-web): update ui color contract" ``` --- ## Self-Review - [ ] Spec coverage: shell, dashboard landing text, single overlay page, linked table names, blueprint ordered overlays, server detail page, admin pages, standard forms, SSE logs, no WebSockets, neutral light/dark tokens. - [ ] No external frontend dependencies are introduced. - [ ] No inline JavaScript is introduced. - [ ] Draft JSON route tests are either intentionally retained or replaced by form-based tests. - [ ] Access rules are covered for normal users and admins. - [ ] Docs are updated to remove the fixed `#0F766E` contract. - [ ] Final verification command is `pytest l4d2web/tests -q`.