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