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 `