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

Dashboard

+

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

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

Overlays

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

Servers

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

Server: {{ server.name }}

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

Blueprint

+

Overlay order

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

Arguments

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

Config

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

Current / Recent Job

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

+  {% else %}
+  

No jobs have run for this server.

+ {% endif %} +
+ +
+

Server Log

+

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

Blueprints

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

Blueprint: {{ blueprint.name }}

+
+ + +

Overlay order matters: the first overlay has highest precedence.

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

Admin

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

Users

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

Jobs

+ + + + {% for job, user, server in rows %} + + + + + + + + + + {% else %} + + {% endfor %} + +
IDOperationStateUserServerCreatedFinished
{{ job.id }}{{ job.operation }}{{ job.state }}{{ user.username }}{% if server %}{{ server.name }}{% else %}-{% endif %}{{ job.created_at }}{{ job.finished_at or "-" }}
No jobs found.
+
+{% endblock %} +``` + +- [ ] **Step 5: Run tests and verify pass** + +Run: `pytest l4d2web/tests/test_pages.py::test_non_admin_does_not_see_admin_nav l4d2web/tests/test_pages.py::test_admin_can_use_admin_pages l4d2web/tests/test_pages.py::test_non_admin_cannot_open_admin_pages -q` + +Expected: PASS. + +- [ ] **Step 6: Commit admin pages** + +```bash +git add l4d2web/routes/page_routes.py l4d2web/templates/admin.html l4d2web/templates/admin_users.html l4d2web/templates/admin_jobs.html l4d2web/tests/test_pages.py +git commit -m "feat(l4d2-web): add admin landing and system pages" +``` + +### Task 6: Update stale color-contract docs and run full web test suite + +**Files:** +- Modify: `AGENTS.md` +- Modify: `docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md` +- Test: `l4d2web/tests/*.py` + +- [ ] **Step 1: Update stale fixed-link-color wording** + +In `AGENTS.md`, replace: + +```markdown +- Custom CSS with consistent link color `#0F766E`. +``` + +with: + +```markdown +- Custom CSS with tokenized, consistent link and accent colors. +``` + +In `docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md`, replace: + +```markdown +- consistent link color `#0F766E` +``` + +with: + +```markdown +- tokenized, consistent link and accent colors +``` + +Also update the sample CSS token in that plan from `--color-link: #0F766E;` to: + +```css +--color-link: var(--color-primary); +``` + +- [ ] **Step 2: Verify stale exact color contract is gone from active guidance** + +Run: `rg '#0F766E|0F766E' AGENTS.md docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md l4d2web/static/css` + +Expected: no matches. + +- [ ] **Step 3: Run the focused UI test files** + +Run: `pytest l4d2web/tests/test_pages.py l4d2web/tests/test_overlays.py l4d2web/tests/test_blueprints.py l4d2web/tests/test_servers.py -q` + +Expected: PASS. + +- [ ] **Step 4: Run the full web test suite** + +Run: `pytest l4d2web/tests -q` + +Expected: PASS. + +- [ ] **Step 5: Commit docs and final verification updates** + +```bash +git add AGENTS.md docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md +git commit -m "docs(l4d2-web): update ui color contract" +``` + +--- + +## Self-Review + +- [ ] Spec coverage: shell, dashboard landing text, single overlay page, linked table names, blueprint ordered overlays, server detail page, admin pages, standard forms, SSE logs, no WebSockets, neutral light/dark tokens. +- [ ] No external frontend dependencies are introduced. +- [ ] No inline JavaScript is introduced. +- [ ] Draft JSON route tests are either intentionally retained or replaced by form-based tests. +- [ ] Access rules are covered for normal users and admins. +- [ ] Docs are updated to remove the fixed `#0F766E` contract. +- [ ] Final verification command is `pytest l4d2web/tests -q`. diff --git a/docs/superpowers/specs/2026-05-06-l4d2-web-ui-design.md b/docs/superpowers/specs/2026-05-06-l4d2-web-ui-design.md new file mode 100644 index 0000000..fc901f0 --- /dev/null +++ b/docs/superpowers/specs/2026-05-06-l4d2-web-ui-design.md @@ -0,0 +1,181 @@ +# L4D2 Web UI Design + +**Goal:** Replace the current minimal web UI with a functional, understated admin-console interface for technical end users who manage L4D2 servers, blueprints, overlays, jobs, and logs. + +**Approval status:** User-approved design direction. Implementation must not start until the user explicitly approves moving from this spec to an implementation plan. + +## Context + +The existing web app is Flask with server-rendered Jinja templates, vendored HTMX, custom CSS, vanilla JavaScript, SQLite state, in-process jobs, and SSE log streams. The UI work must stay inside that architecture. + +The current `l4d2web` code is a rough draft. Implementation may replace, remove, or reorganize existing web routes, templates, CSS, JavaScript, and tests when that better serves this spec. Preserve the approved architecture and product contracts, not draft implementation details. + +The original web-app plan required a fixed link color of `#0F766E`. This spec supersedes that exact color requirement based on user feedback. Link and accent colors must still be consistent and tokenized, but they do not need to use `#0F766E`. The implementation plan should update older project docs that still describe the exact color as mandatory. + +## Design Principles + +- Build a plain functional admin console, not a visually themed game interface. +- Keep style minimal and comfortable for technical end users. +- Use color only when it communicates meaning or interaction. +- Use neutral backgrounds, surfaces, borders, and text for the basic layout. +- Use accent colors sparingly for links, primary actions, focus states, status, warnings, and errors. +- Avoid decorative gradients, decorative color blocks, colorful shadows, and unnecessary visual effects. +- Use one regular font stack for the app. +- Use monospace only where it improves readability, especially logs and command-like content. +- Keep the type scale small. Use larger text only for page titles and section headings. +- Prefer weight, spacing, and borders over many font sizes. +- Keep pages usable and understandable with standard HTML. +- Use HTMX only where partial updates clearly improve UX. +- Use SSE for live server-to-browser streams such as logs and job output. +- Do not introduce WebSockets for v1. + +## Visual System + +The UI should use CSS variables as the single source for visual consistency. Component CSS should reference tokens instead of hard-coding colors, spacing, borders, or radii. + +The token set should include: + +- Background colors, including page background, surface background, muted surface, and log surface. +- Text colors, including default, muted, inverted, and log text. +- Border and line colors. +- Interaction colors, including link, primary action, danger, warning, success, and focus. +- A spacing scale derived from a base value, using names like `--space-xs`, `--space-s`, `--space-m`, `--space-l`, `--space-xl`, and `--space-2xl`. +- Border radius values, used only where rounded corners help readability. +- Reusable line tokens such as `--line` for standard borders. + +The default light theme should use a plain light gray page background, white surfaces, and subtle gray borders. The current radial teal background should be removed. + +Dark mode should follow the user's system preference with `@media (prefers-color-scheme: dark)`. Dark mode should override variables only. Component CSS should not duplicate light and dark versions unless unavoidable. + +Logs should stay dark monospace panels in both modes because that best matches terminal output and improves contrast for streaming logs. + +## Navigation + +The app shell should use a single horizontal header. + +Left navigation: + +```text +left4me | servers | blueprints | overlays +``` + +Right navigation: + +```text +admin | user@email | logout +``` + +Navigation behavior: + +- `left4me` links to `/dashboard`. +- `servers` links to `/servers`. +- `blueprints` links to `/blueprints`. +- `overlays` links to `/overlays`. +- `admin` appears only for admins and links to `/admin`. +- The current user identifier appears as text or as a future account link. +- Logout should use a small POST form styled like a nav link, not a GET link, so it remains compatible with CSRF rules. + +## Routes And Page Structure + +`/dashboard` should be a placeholder-only landing page for now. It should not show metrics, cards, or summaries yet. Example text: "Dashboard placeholder. Use the navigation to manage servers, blueprints, and overlays." + +`/servers` should show a comfortable table of the current user's servers. The server name should link to `/servers/`. The table should not have a separate "View" column. Useful columns include name, port, blueprint, desired state, actual state, active job, and real actions. + +`/servers/` should be action-oriented and log-focused. It should show lifecycle actions near the title, status metadata, linked blueprint information, current or recent job information, a job log panel, and a server log panel. It must not expose server-level overrides because servers remain live-linked to blueprints. + +`/blueprints` should show the current user's private blueprints in a table. Blueprint names should link to `/blueprints/`. Useful columns include name, overlay count, linked server count, and real actions. + +`/blueprints/` should show a normal detail/edit form. It should include blueprint name, ordered overlays, arguments, and config. Arguments and config should be multiline textareas. The page should explain that overlay order matters and that the first overlay has highest precedence. + +`/overlays` should be the only overlay catalog page. All authenticated users can view overlays. Admins see create, edit, and delete controls on the same page. Non-admins do not see edit controls. + +`/admin` should be a simple admin landing page with links to admin-only areas such as users and jobs. Overlay administration should not be duplicated here. + +`/admin/users` should show a user table with username, admin flag, and account metadata. Usernames should link to a detail page only if a detail page exists. + +`/admin/jobs` should show a job table with operation, state, server, user, timestamps, and log access. Job IDs or operations may link to job detail/log pages only if those pages exist. + +## Table Rules + +Tables should use normal comfortable spacing and clear empty states. + +If an entity has a detail page, the entity name should be the link to that page. Do not add a separate row action just for viewing details. + +The final table column should be reserved for real actions such as start, stop, edit, delete, promote, demote, or reassign. + +## Forms And Progressive Enhancement + +Use standard HTML forms by default. Full-page POST and redirect behavior is acceptable for create, update, delete, and lifecycle actions. + +Use HTMX only where it improves UX without adding architectural complexity. Good candidates include status fragments, row refreshes after lifecycle actions, small validation feedback, and log/status panels that benefit from partial replacement. + +Keep JavaScript unobtrusive when it is needed. Do not put inline JavaScript in templates. Static JavaScript files should enhance regular HTML with `data-*` attributes and normal form fields. + +## Blueprint Editing + +Blueprint editing can stay form-based for v1. + +The blueprint page should include: + +- A name field. +- A selected overlays section that displays overlays in saved order. +- Form controls to add, remove, and reorder overlays. +- Multiline `arguments` textarea, interpreted as one argument or argument group per non-empty line. +- Multiline `config` textarea, interpreted as one server config line per non-empty line. +- A primary save action for normal blueprint edits. + +Overlay ordering may use full-page form submissions for v1. A simple table with one row per overlay, a checkbox to include it, and a numeric order field is acceptable. Dedicated add, remove, move-up, and move-down submit buttons are also acceptable if they preserve the current name, arguments, config, and ordered overlay IDs so users do not lose in-progress edits. + +Saved overlay IDs must remain ordered. If hidden fields are used, repeated `overlay_ids` inputs should appear in the intended order so Flask can read them with `request.form.getlist("overlay_ids")`. If numeric order fields are used, the server should sort selected overlay IDs by those fields before persisting `BlueprintOverlay.position`. + +## Server Detail Page + +The server detail page should prioritize operations and diagnostics. + +Recommended section order: + +- Title and lifecycle action buttons. +- Status metadata including name, port, blueprint, desired state, actual state, display state, and last error. +- Linked blueprint summary including overlay order, arguments, and config. +- Current or recent job summary. +- Job log panel. +- Server log panel. + +Lifecycle actions should use standard POST forms. Destructive delete should require confirmation. If a job is active, conflicting actions should be disabled or clearly blocked. + +Job logs and server logs should stream with SSE. Status may use HTMX polling, SSE, or normal refresh depending on the smallest reliable implementation for the page. + +## Access Rules + +Authenticated users can access dashboard, servers, blueprints, and overlays. + +Blueprints remain private per user. + +Servers remain private per user. + +Overlays are visible to all authenticated users. + +Only admins can create, edit, or delete overlays. + +Only admins can access `/admin`, `/admin/users`, and `/admin/jobs`. + +Non-admin direct access to admin pages should return forbidden. + +## Out Of Scope + +- External frontend frameworks or new frontend dependencies. +- WebSockets. +- Manual dark-mode toggle. +- Game-themed visual design. +- Dashboard metrics or summary widgets. +- Client-side overlay drag-and-drop ordering for v1. +- Server-level blueprint overrides. +- Duplicated overlay administration under `/admin/overlays`. + +## Implementation Boundaries + +This design is intentionally UI-focused. It does not change the host library contract, job execution model, blueprint privacy model, or desired-vs-actual server state model. + +Existing draft web code is not a compatibility boundary. When existing draft code conflicts with this spec, prefer changing the code and tests to this spec over preserving old placeholder behavior. + +Before implementation starts, create a separate implementation plan and get explicit user approval.