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

```html
{% 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:

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

```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 %}
<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**

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

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

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

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

```html
{% 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`:

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

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

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

```html
{% 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:

```html
{% 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**

```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 %}
<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`:

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

```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**

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