left4me/docs/superpowers/plans/2026-05-06-l4d2-web-ui.md
2026-05-06 11:34:23 +02:00

48 KiB

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:

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:

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:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="csrf-token" content="{{ session.get('csrf_token', '') }}">
    <title>{% block title %}left4me{% endblock %}</title>
    <link rel="stylesheet" href="{{ url_for('static', filename='css/tokens.css') }}">
    <link rel="stylesheet" href="{{ url_for('static', filename='css/layout.css') }}">
    <link rel="stylesheet" href="{{ url_for('static', filename='css/components.css') }}">
    <link rel="stylesheet" href="{{ url_for('static', filename='css/logs.css') }}">
  </head>
  <body>
    <header class="site-header">
      <div class="site-header-inner">
        <nav class="primary-nav" aria-label="Main navigation">
          <a class="brand" href="/dashboard">left4me</a>
          <a href="/servers">servers</a>
          <a href="/blueprints">blueprints</a>
          <a href="/overlays">overlays</a>
        </nav>
        {% if g.user %}
        <nav class="account-nav" aria-label="Account navigation">
          {% if g.user.admin %}<a href="/admin">admin</a>{% endif %}
          <span class="muted">{{ g.user.username }}</span>
          <form method="post" action="/logout" class="inline-form">
            <input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
            <button class="link-button" type="submit">logout</button>
          </form>
        </nav>
        {% endif %}
      </div>
    </header>
    <main class="container">
      {% block content %}{% endblock %}
    </main>
    <script src="{{ url_for('static', filename='vendor/htmx.min.js') }}"></script>
    <script src="{{ url_for('static', filename='js/csrf.js') }}"></script>
    <script src="{{ url_for('static', filename='js/sse.js') }}"></script>
  </body>
</html>

Change l4d2web/templates/dashboard.html to:

{% extends "base.html" %}

{% block title %}Dashboard | left4me{% endblock %}

{% block content %}
<section class="panel">
  <h1>Dashboard</h1>
  <p class="muted">Use the navigation to manage servers, blueprints, and overlays.</p>
</section>
{% endblock %}
  • Step 4: Update CSS tokens and layout

Replace l4d2web/static/css/tokens.css with:

: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:

.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
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:

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:

@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/<int:overlay_id>")
@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/<int:overlay_id>/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:

@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:

{% extends "base.html" %}

{% block title %}Overlays | left4me{% endblock %}

{% block content %}
<section class="panel">
  <div class="page-heading">
    <h1>Overlays</h1>
  </div>

  {% if g.user.admin %}
  <form method="post" action="/overlays" class="stack form-panel">
    <input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
    <label>Name <input name="name" required></label>
    <label>Path <input name="path" required placeholder="/opt/l4d2/overlays/example"></label>
    <button type="submit">Add overlay</button>
  </form>
  {% endif %}

  <table class="table">
    <thead><tr><th>Name</th><th>Path</th>{% if g.user.admin %}<th>Actions</th>{% endif %}</tr></thead>
    <tbody>
      {% for overlay in overlays %}
      <tr>
        <td>{{ overlay.name }}</td>
        <td class="muted">{{ overlay.path }}</td>
        {% if g.user.admin %}
        <td>
          <form method="post" action="/overlays/{{ overlay.id }}" class="inline-form">
            <input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
            <input name="name" value="{{ overlay.name }}" required>
            <input name="path" value="{{ overlay.path }}" required>
            <button type="submit">Save</button>
          </form>
          <form method="post" action="/overlays/{{ overlay.id }}/delete" class="inline-form">
            <input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
            <button class="danger" type="submit">Delete</button>
          </form>
        </td>
        {% endif %}
      </tr>
      {% else %}
      <tr><td colspan="{% if g.user.admin %}3{% else %}2{% endif %}" class="muted">No overlays configured.</td></tr>
      {% endfor %}
    </tbody>
  </table>
</section>
{% 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
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:

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 '<a href="/servers/1">alpha</a>' 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:

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:

LIFECYCLE_OPERATIONS = {"initialize", "start", "stop", "delete"}


@bp.post("/servers/<int:server_id>/<operation>")
@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:

@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:

@bp.get("/servers/<int:server_id>")
@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:

{% extends "base.html" %}

{% block title %}Servers | left4me{% endblock %}

{% block content %}
<section class="panel">
  <h1>Servers</h1>
  <table class="table">
    <thead><tr><th>Name</th><th>Port</th><th>Blueprint</th><th>Desired</th><th>Actual</th></tr></thead>
    <tbody>
      {% for server, blueprint in rows %}
      <tr>
        <td><a href="/servers/{{ server.id }}">{{ server.name }}</a></td>
        <td>{{ server.port }}</td>
        <td><a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a></td>
        <td>{{ server.desired_state }}</td>
        <td>{{ server.actual_state }}</td>
      </tr>
      {% else %}
      <tr><td colspan="5" class="muted">No servers configured.</td></tr>
      {% endfor %}
    </tbody>
  </table>
</section>
{% endblock %}

Replace l4d2web/templates/server_detail.html with:

{% extends "base.html" %}

{% block title %}Server {{ server.name }} | left4me{% endblock %}

{% block content %}
<section class="panel">
  <div class="page-heading">
    <h1>Server: {{ server.name }}</h1>
    <div class="button-row">
      {% for operation in ["initialize", "start", "stop"] %}
      <form method="post" action="/servers/{{ server.id }}/{{ operation }}" class="inline-form">
        <input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
        <button type="submit">{{ operation }}</button>
      </form>
      {% endfor %}
      <form method="post" action="/servers/{{ server.id }}/delete" class="inline-form">
        <input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
        <button class="danger" type="submit">delete</button>
      </form>
    </div>
  </div>

  <table class="definition-table">
    <tbody>
      <tr><th>Name</th><td>{{ server.name }}</td></tr>
      <tr><th>Port</th><td>{{ server.port }}</td></tr>
      <tr><th>Blueprint</th><td>{% if blueprint %}<a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a>{% endif %}</td></tr>
      <tr><th>Desired state</th><td>{{ server.desired_state }}</td></tr>
      <tr><th>Actual state</th><td>{{ server.actual_state }}</td></tr>
      <tr><th>Last error</th><td>{{ server.last_error or "-" }}</td></tr>
    </tbody>
  </table>
</section>

<section class="panel">
  <h2>Blueprint</h2>
  <h3>Overlay order</h3>
  <ol>
    {% for name in overlay_names %}<li>{{ name }}</li>{% else %}<li class="muted">No overlays configured.</li>{% endfor %}
  </ol>
  <h3>Arguments</h3>
  <pre class="code-block">{{ arguments | join('\n') }}</pre>
  <h3>Config</h3>
  <pre class="code-block">{{ config_lines | join('\n') }}</pre>
</section>

<section class="panel">
  <h2>Current / Recent Job</h2>
  {% if latest_job %}
  <table class="definition-table"><tbody><tr><th>Operation</th><td>{{ latest_job.operation }}</td></tr><tr><th>State</th><td>{{ latest_job.state }}</td></tr></tbody></table>
  <pre class="log-stream" data-sse-url="/jobs/{{ latest_job.id }}/stream"></pre>
  {% else %}
  <p class="muted">No jobs have run for this server.</p>
  {% endif %}
</section>

<section class="panel">
  <h2>Server Log</h2>
  <pre class="log-stream" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
</section>
{% endblock %}

Update l4d2web/static/js/sse.js so it binds every element with data-sse-url:

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
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:

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 '<a href="/blueprints/1">default</a>' 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:

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:

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:

@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:

@bp.post("/blueprints/<int:blueprint_id>")
@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:

@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:

@bp.get("/blueprints/<int:blueprint_id>")
@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:

{% extends "base.html" %}

{% block title %}Blueprints | left4me{% endblock %}

{% block content %}
<section class="panel">
  <h1>Blueprints</h1>
  <form method="post" action="/blueprints" class="stack form-panel">
    <input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
    <label>Name <input name="name" required></label>
    <label>Arguments <textarea name="arguments"></textarea></label>
    <label>Config <textarea name="config"></textarea></label>
    <button type="submit">Create blueprint</button>
  </form>
  <table class="table">
    <thead><tr><th>Name</th><th>Created</th><th>Updated</th><th>Actions</th></tr></thead>
    <tbody>
      {% for blueprint in blueprints %}
      <tr>
        <td><a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a></td>
        <td>{{ blueprint.created_at }}</td>
        <td>{{ blueprint.updated_at }}</td>
        <td>
          <form method="post" action="/blueprints/{{ blueprint.id }}/delete" class="inline-form">
            <input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
            <button class="danger" type="submit">Delete</button>
          </form>
        </td>
      </tr>
      {% else %}
      <tr><td colspan="4" class="muted">No blueprints configured.</td></tr>
      {% endfor %}
    </tbody>
  </table>
</section>
{% endblock %}

Create l4d2web/templates/blueprint_detail.html with:

{% extends "base.html" %}

{% block title %}Blueprint {{ blueprint.name }} | left4me{% endblock %}

{% block content %}
<section class="panel">
  <h1>Blueprint: {{ blueprint.name }}</h1>
  <form method="post" action="/blueprints/{{ blueprint.id }}" class="stack">
    <input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
    <label>Name <input name="name" value="{{ blueprint.name }}" required></label>
    <p class="muted">Overlay order matters: the first overlay has highest precedence.</p>
    <table class="table">
      <thead><tr><th>Use</th><th>Order</th><th>Overlay</th></tr></thead>
      <tbody>
        {% for overlay in all_overlays %}
        <tr>
          <td><input type="checkbox" name="overlay_ids" value="{{ overlay.id }}" {% if overlay.id in selected_overlay_ids %}checked{% endif %}></td>
          <td><input class="position-input" name="overlay_position_{{ overlay.id }}" value="{{ overlay_positions.get(overlay.id, '') }}" inputmode="numeric"></td>
          <td>{{ overlay.name }}</td>
        </tr>
        {% else %}
        <tr><td colspan="3" class="muted">No overlays available.</td></tr>
        {% endfor %}
      </tbody>
    </table>
    <label>Arguments <textarea name="arguments">{{ arguments | join('\n') }}</textarea></label>
    <label>Config <textarea name="config">{{ config_lines | join('\n') }}</textarea></label>
    <button type="submit">Save blueprint</button>
  </form>
</section>
{% 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
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:

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:

@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:

{% extends "base.html" %}

{% block title %}Admin | left4me{% endblock %}

{% block content %}
<section class="panel">
  <h1>Admin</h1>
  <ul class="link-list">
    <li><a href="/admin/users">Users</a></li>
    <li><a href="/admin/jobs">Jobs</a></li>
  </ul>
</section>
{% endblock %}

Create l4d2web/templates/admin_users.html:

{% extends "base.html" %}

{% block title %}Admin Users | left4me{% endblock %}

{% block content %}
<section class="panel">
  <h1>Users</h1>
  <table class="table">
    <thead><tr><th>Username</th><th>Admin</th><th>Created</th><th>Updated</th></tr></thead>
    <tbody>
      {% for user in users %}
      <tr><td>{{ user.username }}</td><td>{{ "yes" if user.admin else "no" }}</td><td>{{ user.created_at }}</td><td>{{ user.updated_at }}</td></tr>
      {% else %}
      <tr><td colspan="4" class="muted">No users found.</td></tr>
      {% endfor %}
    </tbody>
  </table>
</section>
{% endblock %}

Create l4d2web/templates/admin_jobs.html:

{% extends "base.html" %}

{% block title %}Admin Jobs | left4me{% endblock %}

{% block content %}
<section class="panel">
  <h1>Jobs</h1>
  <table class="table">
    <thead><tr><th>ID</th><th>Operation</th><th>State</th><th>User</th><th>Server</th><th>Created</th><th>Finished</th></tr></thead>
    <tbody>
      {% for job, user, server in rows %}
      <tr>
        <td>{{ job.id }}</td>
        <td>{{ job.operation }}</td>
        <td>{{ job.state }}</td>
        <td>{{ user.username }}</td>
        <td>{% if server %}<a href="/servers/{{ server.id }}">{{ server.name }}</a>{% else %}-{% endif %}</td>
        <td>{{ job.created_at }}</td>
        <td>{{ job.finished_at or "-" }}</td>
      </tr>
      {% else %}
      <tr><td colspan="7" class="muted">No jobs found.</td></tr>
      {% endfor %}
    </tbody>
  </table>
</section>
{% 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
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:

- Custom CSS with consistent link color `#0F766E`.

with:

- Custom CSS with tokenized, consistent link and accent colors.

In docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md, replace:

- consistent link color `#0F766E`

with:

- tokenized, consistent link and accent colors

Also update the sample CSS token in that plan from --color-link: #0F766E; to:

--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
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.