diff --git a/docs/superpowers/plans/2026-05-17-textarea-editor-v2.md b/docs/superpowers/plans/2026-05-17-textarea-editor-v2.md new file mode 100644 index 0000000..83b1d5a --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-textarea-editor-v2.md @@ -0,0 +1,1351 @@ +# Textarea Code Editor v2 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:** Wire CodeMirror 6 (bundled, self-hosted) into three textareas in `l4d2web` with syntax highlighting (srccfg + bash) and identifier-as-you-type autocomplete fed by ~2196 cvars/commands generated from `./cvar_list`, without changing the form-POST or `fetch('/files/save')` contracts. + +**Architecture:** Pre-built esbuild IIFE bundle (`editor.bundle.js`) exposing `window.__editor.mount(textarea, opts) → controller`. Submit-time copy form bridge: capture-phase `submit` handler copies `controller.getValue()` into the hidden textarea once per submit; JSON-save path calls `getValue()` directly. cm6 owns the live doc. Per-page CSS asset partial keeps the editor off pages that don't mount one. + +**Tech Stack:** CodeMirror 6 (`@codemirror/{state,view,commands,language,autocomplete}` + `@codemirror/legacy-modes`), esbuild for bundling, Python `pytest` + Playwright for tests, vanilla JS glue, Jinja templates, Flask backend (untouched). + +**Spec:** `docs/superpowers/specs/2026-05-17-textarea-editor-v2-design.md`. **Handoff context (read first):** `docs/superpowers/specs/2026-05-17-textarea-editor-handoff.md`. + +--- + +## File map + +What each file owns. Tasks below produce them. + +| File | Owner / purpose | +|---|---| +| `l4d2web/scripts/editor-src/package.json` | npm dep manifest (pinned) | +| `l4d2web/scripts/editor-src/package-lock.json` | reproducible build lock | +| `l4d2web/scripts/editor-src/.gitignore` | excludes `node_modules/` | +| `l4d2web/scripts/editor-src/editor-entry.js` | imports cm6 modules; exports `window.__editor` façade | +| `l4d2web/scripts/editor-src/srccfg-mode.js` | `StreamLanguage.define(…)` for Source-engine `.cfg` | +| `l4d2web/scripts/editor-src/themes.js` | light + dark `EditorView.theme()` exports | +| `l4d2web/scripts/editor-src/autocomplete.js` | `CompletionSource` over the vocab JSON | +| `l4d2web/scripts/build-editor.sh` | esbuild invocation; outputs to `static/vendor/` | +| `l4d2web/scripts/build-vocab.py` | parse `./cvar_list` → `srccfg-vocab.json` | +| `l4d2web/l4d2web/static/vendor/editor.bundle.js` | built bundle (committed artifact) | +| `l4d2web/l4d2web/static/vendor/editor.bundle.css` | built CSS (committed artifact) | +| `l4d2web/l4d2web/static/vendor/editor.bundle.sha256` | integrity hashes | +| `l4d2web/l4d2web/static/vendor/README.md` | build cmd, dep versions, regen instructions | +| `l4d2web/l4d2web/static/js/editor.js` | un-bundled glue: mount loop, submit-capture, `__filesEditor` alias | +| `l4d2web/l4d2web/static/css/editor.css` | CSS-variable bridge: cm6 classes ← `tokens.css` | +| `l4d2web/l4d2web/static/css/tokens.css` | gains `--syntax-*` and `--cm-*` variables in light + dark blocks | +| `l4d2web/l4d2web/static/data/srccfg-vocab.json` | generated vocab (committed) | +| `l4d2web/l4d2web/templates/_editor_assets.html` | Jinja partial: nonce'd ``/` + +``` + +`defer` ensures `editor.bundle.js` (which sets `window.__editor`) executes +before `editor.js` (which reads it), since `defer` scripts preserve +document order. + +- [ ] **Step 2: Commit** + +```bash +git add l4d2web/l4d2web/templates/_editor_assets.html +git commit -m "feat(editor-v2): _editor_assets.html Jinja partial" +``` + +--- + +## Task 11: Form-contract pytest (pre-wiring gate) + +**Files:** +- Modify: `l4d2web/tests/test_blueprints.py` +- Modify: `l4d2web/tests/test_script_overlay_routes.py` + +These tests pin the form-POST contract that the editor must preserve. +They should pass *today*, and continue passing after Task 12's +template wiring. + +- [ ] **Step 1: Read the current shape** + +```bash +rg -n "def test_" l4d2web/tests/test_blueprints.py | head -20 +rg -n "def test_" l4d2web/tests/test_script_overlay_routes.py | head -20 +``` + +Confirm the existing tests use a `client` fixture and the seeded user / blueprint shape. + +- [ ] **Step 2: Add the form-contract test to `test_blueprints.py`** + +Append (adapt fixtures to match what's already there): + +```python +def test_blueprint_config_form_post_contract(client, login_dev, demo_blueprint): + """The blueprint detail form must accept POST with `config` field + and persist verbatim — pinned regardless of editor-bundle changes.""" + config_value = "// pinned by form-contract test\nsv_cheats 0\n" + resp = client.post( + f"/blueprints/{demo_blueprint.id}", + data={"name": demo_blueprint.name, "config": config_value, "arguments": ""}, + follow_redirects=False, + ) + assert resp.status_code in (302, 303), resp.data + # Reload and verify persistence + from l4d2web.models import Blueprint + refreshed = client.application.extensions["sqlalchemy"].db.session.get(Blueprint, demo_blueprint.id) + assert refreshed.config == config_value +``` + +Adapt the imports + how to load `Blueprint` from the session to match +the test file's existing conventions. If a similar fixture +(`login_dev`, `demo_blueprint`) doesn't exist yet, write one in +`conftest.py` modeled on the e2e `live_server` seeding. + +- [ ] **Step 3: Add the form-contract test to `test_script_overlay_routes.py`** + +Same shape: + +```python +def test_overlay_script_form_post_contract(client, login_dev, demo_script_overlay): + script_value = "#!/usr/bin/env bash\necho pinned\n" + resp = client.post( + f"/overlays/{demo_script_overlay.id}", + data={"name": demo_script_overlay.name, "script": script_value}, + follow_redirects=False, + ) + assert resp.status_code in (302, 303), resp.data + # Reload and verify + from l4d2web.models import Overlay + refreshed = client.application.extensions["sqlalchemy"].db.session.get(Overlay, demo_script_overlay.id) + assert refreshed.script == script_value +``` + +- [ ] **Step 4: Run** + +From `l4d2web/`: `uv run pytest tests/test_blueprints.py::test_blueprint_config_form_post_contract tests/test_script_overlay_routes.py::test_overlay_script_form_post_contract -v` +Expected: 2 passed (today, *before* any template change). + +- [ ] **Step 5: Commit** + +```bash +git add l4d2web/tests/test_blueprints.py l4d2web/tests/test_script_overlay_routes.py +git commit -m "test(editor-v2): pin form-POST contract before wiring" +``` + +--- + +## Task 12: Template wiring (the three textareas) + +**Files:** +- Modify: `l4d2web/l4d2web/templates/blueprint_detail.html` (line 52) +- Modify: `l4d2web/l4d2web/templates/overlay_detail.html` (lines 25, 178) +- Modify: `l4d2web/tests/test_blueprints.py` (add GET-asserts-markup test) +- Modify: `l4d2web/tests/test_script_overlay_routes.py` (same) + +TDD: write the GET assertion test, see it fail, edit templates, see it pass. + +- [ ] **Step 1: Write the failing GET test in `test_blueprints.py`** + +```python +def test_blueprint_get_includes_editor_markup(client, login_dev, demo_blueprint): + resp = client.get(f"/blueprints/{demo_blueprint.id}") + assert resp.status_code == 200 + body = resp.data.decode() + assert 'data-editor-language="srccfg"' in body + assert 'vendor/editor.bundle.js' in body + assert 'js/editor.js' in body +``` + +- [ ] **Step 2: Run it, watch it fail** + +From `l4d2web/`: `uv run pytest tests/test_blueprints.py::test_blueprint_get_includes_editor_markup -v` +Expected: FAIL with `assert 'data-editor-language="srccfg"' in body`. + +- [ ] **Step 3: Add the attr + partial to `blueprint_detail.html`** + +Find line 52: +```html + +``` +Change to: +```html + +``` + +Find the `{% block extra_head %}` (or equivalent) block and add: +```html +{% include "_editor_assets.html" %} +``` +If no such block exists, include just before the closing `` or at the bottom of `{% block content %}`. + +- [ ] **Step 4: Re-run the test, watch it pass** + +`uv run pytest tests/test_blueprints.py::test_blueprint_get_includes_editor_markup -v` +Expected: PASS. + +- [ ] **Step 5: Mirror tests + edits for `overlay_detail.html`** + +Add to `test_script_overlay_routes.py`: + +```python +def test_overlay_script_get_includes_editor_markup(client, login_dev, demo_script_overlay): + resp = client.get(f"/overlays/{demo_script_overlay.id}") + body = resp.data.decode() + assert 'data-editor-language="bash"' in body + assert 'vendor/editor.bundle.js' in body + + +def test_overlay_files_get_includes_editor_markup(client, login_dev, demo_files_overlay): + resp = client.get(f"/overlays/{demo_files_overlay.id}") + body = resp.data.decode() + assert 'data-editor-language="auto"' in body + assert 'data-editor-language-select' in body + assert 'data-editor-filename' in body +``` + +Run, watch fail. + +Edit `overlay_detail.html`: +- Line 25 textarea: add `data-editor-language="bash"`. +- Line 178 textarea: add `data-editor-language="auto"` and + `class="files-editor-content"` (the latter already exists per current + source — verify). Add a sibling `` to `bash` re-highlights. + +- [ ] **Step 6: Commit** + +```bash +git add l4d2web/l4d2web/static/js/files-overlay.js +git commit -m "feat(editor-v2): files-overlay reads/writes via window.__filesEditor" +``` + +--- + +## Task 14: Playwright e2e test (`test_editor.py`) + +**Files:** +- Create: `l4d2web/tests/e2e/test_editor.py` +- Modify: `l4d2web/tests/e2e/conftest.py` if the `live_server` fixture needs to seed a `.cfg`-content blueprint. + +- [ ] **Step 1: Check the existing `live_server` fixture** + +```bash +cat l4d2web/tests/e2e/conftest.py +``` + +Confirm it seeds a blueprint with srccfg-shaped content (the dev-server +script already does — copy that seeding if not already in the conftest). + +- [ ] **Step 2: Write the e2e test** + +```python +import pytest +from playwright.sync_api import Page, expect + +pytestmark = pytest.mark.e2e + + +def _login(page: Page, base_url: str) -> None: + page.goto(f"{base_url}/login") + page.fill('input[name="username"]', "dev") + page.fill('input[name="password"]', "devdevdev") + page.click('button[type="submit"]') + + +def test_blueprint_autocomplete_accept_and_submit(page: Page, live_server) -> None: + base = live_server["base_url"] + bp_id = live_server["blueprint_id"] + _login(page, base) + page.goto(f"{base}/blueprints/{bp_id}") + + editor = page.locator(".cm-content") + editor.click() + page.keyboard.press("Control+End") + page.keyboard.type("\nsv_che") + + popup = page.locator(".cm-tooltip-autocomplete") + expect(popup).to_be_visible(timeout=2000) + expect(popup).to_contain_text("sv_cheats") + + page.keyboard.press("Tab") # accept + + # Submit and assert persistence via the hidden textarea + page.evaluate("""() => { + const ta = document.querySelector('textarea[name="config"]'); + const form = ta.closest('form'); + form.requestSubmit(); + }""") + + # After redirect, reload and confirm the value persisted + page.goto(f"{base}/blueprints/{bp_id}") + ta_value = page.evaluate('() => document.querySelector(\'textarea[name="config"]\').value') + assert "sv_cheats" in ta_value + + +def test_copy_preserves_newlines(page: Page, live_server) -> None: + """Regression gate for bug class 1 from the v1 attempt. + cm6 handles multi-line copy correctly out of the box; this test + pins that behavior so a future change doesn't regress it.""" + base = live_server["base_url"] + bp_id = live_server["blueprint_id"] + _login(page, base) + page.goto(f"{base}/blueprints/{bp_id}") + + editor = page.locator(".cm-content") + editor.click() + page.keyboard.press("Control+A") + # Read the selection via the page's Selection API (clipboard-permissions + # are fiddly in CI; Selection.toString() is enough to verify the + # underlying linebreak preservation cm6 guarantees). + selected = page.evaluate("() => window.getSelection().toString()") + assert selected.count("\n") >= 1, f"expected multi-line selection, got: {selected!r}" +``` + +- [ ] **Step 3: Run it** + +From `l4d2web/`: +```bash +uv run pytest -m e2e tests/e2e/test_editor.py -v +``` +(Run with `dangerouslyDisableSandbox: true` on Claude Code's Bash tool — Chromium's Mach-port IPC is sandbox-blocked.) + +Expected: both tests pass. If the first fails on popup visibility, check that `srccfg-vocab.json` is fetchable (network tab) and that `sv_cheats` is actually in the vocab. + +- [ ] **Step 4: Commit** + +```bash +git add l4d2web/tests/e2e/test_editor.py l4d2web/tests/e2e/conftest.py +git commit -m "test(editor-v2): Playwright e2e + copy regression gate" +``` + +--- + +## Task 15: Docs + final smoke + +**Files:** +- Modify: `AGENTS.md` (add Node + npm + build script invocation) +- Verify: dev-server smoke against all three textareas + +- [ ] **Step 1: Inspect existing AGENTS.md edits before touching it** + +`AGENTS.md` is already `M` from before this branch started. Inspect: + +```bash +git diff AGENTS.md +``` + +Add the new content alongside, don't clobber. + +- [ ] **Step 2: Add an editor-rebuild section to AGENTS.md** + +Append (adjust to match existing section structure): + +```markdown +### Editor bundle (CodeMirror 6) + +The in-browser code editor on blueprint config / overlay script / files +modal is bundled from `l4d2web/scripts/editor-src/` via esbuild. To +rebuild after editing the source: + +``` +./l4d2web/scripts/build-editor.sh +``` + +Requires `node` + `npm` (one-time `npm ci` happens inside the script). +Output overwrites `l4d2web/l4d2web/static/vendor/editor.bundle.{js,css}` +and refreshes `editor.bundle.sha256`. Commit all four files. + +Vocab regeneration (after replacing `./cvar_list`): + +``` +./l4d2web/scripts/build-vocab.py +``` +``` + +- [ ] **Step 3: End-to-end local smoke** + +```bash +rm -rf .tmp/dev-server # idempotent reseed +./scripts/dev-server.py +``` + +Visit each of `/blueprints/1`, `/overlays/1`, `/overlays/2` and verify: + +| Page | Expected | +|---|---| +| `/blueprints/1` (demo-srccfg) | cm6 mounts; srccfg syntax highlighted; typing `sv_che` opens autocomplete with `sv_cheats`; Tab accepts; form Save persists | +| `/overlays/1` (demo-bash) | cm6 mounts; bash syntax highlighted; form Save persists | +| `/overlays/2` (demo-files) | cm6 mounts; opening `test.cfg` shows srccfg highlighting; language `