# 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 `<link>`/`<script>` tags |
| `l4d2web/l4d2web/templates/blueprint_detail.html` | + `data-editor-language="srccfg"` on line-52 textarea; include partial |
| `l4d2web/l4d2web/templates/overlay_detail.html` | + `data-editor-language="bash"` on line-25 textarea; + `data-editor-language="auto"` + `<select>` on line-178 textarea; include partial |
| `l4d2web/l4d2web/static/js/files-overlay.js` | 5 reads + 3 writes routed via `window.__filesEditor` |
| `l4d2web/tests/test_blueprints.py` | extend: form-contract + GET-asserts-editor-markup tests |
| `l4d2web/tests/test_script_overlay_routes.py` | extend: same shape as test_blueprints.py |
| `l4d2web/tests/e2e/test_editor.py` | **new** Playwright: type→popup→accept→submit; copy regression gate |
| `cvar_list` | input, committed as data file with a header comment |
| `AGENTS.md` | doc `node + npm` + `./scripts/build-editor.sh` |

---

## Task 1: Scaffold `editor-src/` with pinned cm6 deps

**Files:**
- Create: `l4d2web/scripts/editor-src/package.json`
- Create: `l4d2web/scripts/editor-src/.gitignore`
- Create: `l4d2web/scripts/editor-src/editor-entry.js` (stub, exports nothing yet)

- [ ] **Step 1: Write `package.json`**

```json
{
  "name": "l4d2web-editor",
  "version": "0.1.0",
  "description": "CodeMirror 6 bundle for l4d2web textarea upgrade",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "esbuild editor-entry.js --bundle --minify --format=iife --global-name=__editor_pkg --outfile=../../l4d2web/static/vendor/editor.bundle.js --metafile=meta.json"
  },
  "devDependencies": {
    "esbuild": "^0.24.0"
  },
  "dependencies": {
    "@codemirror/state": "^6.5.0",
    "@codemirror/view": "^6.36.0",
    "@codemirror/commands": "^6.8.0",
    "@codemirror/language": "^6.10.0",
    "@codemirror/autocomplete": "^6.18.0",
    "@codemirror/legacy-modes": "^6.4.0"
  }
}
```

- [ ] **Step 2: Write `.gitignore`**

```
node_modules/
meta.json
```

- [ ] **Step 3: Write a stub `editor-entry.js`**

```js
// Façade entry point. Populated in Task 4.
window.__editor = { mount: () => { throw new Error('editor bundle not yet implemented'); } };
```

- [ ] **Step 4: Install deps to produce the lockfile**

Run from `l4d2web/scripts/editor-src/`: `npm install`
Expected: creates `node_modules/` and `package-lock.json`.

- [ ] **Step 5: Verify esbuild is invokable**

Run: `npx esbuild --version`
Expected: prints `0.24.x` (or whatever was resolved).

- [ ] **Step 6: Commit**

```bash
git add l4d2web/scripts/editor-src/package.json \
        l4d2web/scripts/editor-src/package-lock.json \
        l4d2web/scripts/editor-src/.gitignore \
        l4d2web/scripts/editor-src/editor-entry.js
git commit -m "scaffold(editor-v2): pin cm6 deps + editor-src skeleton"
```

---

## Task 2: Vocab generator (`build-vocab.py`)

**Files:**
- Create: `l4d2web/scripts/build-vocab.py`
- Create: `l4d2web/l4d2web/static/data/srccfg-vocab.json` (generated)

The repo-root `cvar_list` is a Source-engine `cvarlist` dump:
`name : value-or-"cmd" : flags : description` (whitespace-padded). Two
header lines, then ~2196 rows. Descriptions may contain extra `:`,
so split with `maxsplit=3`.

- [ ] **Step 1: Write `build-vocab.py`**

Path: `l4d2web/scripts/build-vocab.py` — chmod 755.

```python
#!/usr/bin/env python3
"""Convert ./cvar_list (cvarlist dump) to static/data/srccfg-vocab.json.

Usage: ./l4d2web/scripts/build-vocab.py
Reads from the repo root (auto-detected via this script's path).
Writes idempotently. Run after regenerating cvar_list."""
from __future__ import annotations

import hashlib
import json
import pathlib
import re
import sys

HERE = pathlib.Path(__file__).resolve().parent
REPO_ROOT = HERE.parent.parent
SOURCE = REPO_ROOT / "cvar_list"
DEST = REPO_ROOT / "l4d2web" / "l4d2web" / "static" / "data" / "srccfg-vocab.json"


def parse(text: str) -> tuple[list[dict], list[dict]]:
    cvars: list[dict] = []
    commands: list[dict] = []
    for raw in text.splitlines()[2:]:  # skip "cvar list" + "--------…"
        if not raw.strip():
            continue
        parts = [p.strip() for p in re.split(r"\s*:\s*", raw, maxsplit=3)]
        if len(parts) < 3:
            continue
        name = parts[0]
        value = parts[1]
        desc = parts[3] if len(parts) >= 4 else ""
        target = commands if value == "cmd" else cvars
        target.append({"name": name, "desc": desc} if desc else {"name": name})
    return cvars, commands


def main() -> int:
    if not SOURCE.exists():
        print(f"ERROR: {SOURCE} not found", file=sys.stderr)
        return 1
    text = SOURCE.read_text(encoding="utf-8", errors="replace")
    cvars, commands = parse(text)
    src_sha = hashlib.sha256(text.encode("utf-8")).hexdigest()
    payload = {
        "version": 1,
        "generated_from": "cvar_list",
        "source_sha256": src_sha,
        "cvars": cvars,
        "commands": commands,
    }
    DEST.parent.mkdir(parents=True, exist_ok=True)
    DEST.write_text(json.dumps(payload, indent=2, sort_keys=False) + "\n", encoding="utf-8")
    print(f"wrote {DEST}: {len(cvars)} cvars + {len(commands)} commands")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
```

- [ ] **Step 2: Run it**

From repo root: `./l4d2web/scripts/build-vocab.py`
Expected stdout (numbers may differ by a few entries):
```
wrote …/l4d2web/l4d2web/static/data/srccfg-vocab.json: 1100 cvars + 1096 commands
```

- [ ] **Step 3: Verify shape**

```bash
python3 -c 'import json; d = json.load(open("l4d2web/l4d2web/static/data/srccfg-vocab.json")); print(d["version"], len(d["cvars"]), len(d["commands"])); print(d["cvars"][0]); print(d["commands"][0])'
```
Expected: `1 <N_cvars> <N_cmds>` plus two sample entries with `name` (and optionally `desc`) keys.

- [ ] **Step 4: Verify a known entry**

```bash
python3 -c 'import json; d = json.load(open("l4d2web/l4d2web/static/data/srccfg-vocab.json")); print([c for c in d["cvars"] if c["name"] == "adrenaline_duration"])'
```
Expected: `[{'name': 'adrenaline_duration'}]` (no description on that line in the dump).

- [ ] **Step 5: Commit**

```bash
git add l4d2web/scripts/build-vocab.py \
        l4d2web/l4d2web/static/data/srccfg-vocab.json \
        cvar_list
git commit -m "feat(editor-v2): vocab generator + cvar_list-derived JSON"
```

Note: `cvar_list` is included so the generation is reproducible from the repo alone. Future regenerations replace it and re-run the script.

---

## Task 3: srccfg StreamLanguage mode

**Files:**
- Create: `l4d2web/scripts/editor-src/srccfg-mode.js`

- [ ] **Step 1: Write the mode**

```js
import { StreamLanguage } from "@codemirror/language";

// Source-engine .cfg syntax (server.cfg style).
// Linewise. No nesting. Tokens: comment, string, number, keyword, identifier.
const KEYWORDS = new Set(["exec", "alias", "bind", "unbindall", "wait"]);

export const srccfgLanguage = StreamLanguage.define({
  name: "srccfg",
  startState: () => ({}),
  token(stream) {
    if (stream.eatSpace()) return null;
    if (stream.match("//")) {
      stream.skipToEnd();
      return "comment";
    }
    if (stream.match(/^"(?:[^"\\]|\\.)*"?/)) return "string";
    if (stream.match(/^-?\d+(?:\.\d+)?/)) return "number";
    if (stream.match(/^[A-Za-z_][A-Za-z0-9_]*/)) {
      const word = stream.current();
      return KEYWORDS.has(word) ? "keyword" : "variableName";
    }
    stream.next();
    return null;
  },
  languageData: { commentTokens: { line: "//" } },
});
```

- [ ] **Step 2: Smoke-build to verify the import resolves**

From `l4d2web/scripts/editor-src/`:
```bash
node --input-type=module -e 'import("./srccfg-mode.js").then(m => console.log(Object.keys(m)))'
```
Expected: `[ 'srccfgLanguage' ]` (no thrown error).

- [ ] **Step 3: Commit**

```bash
git add l4d2web/scripts/editor-src/srccfg-mode.js
git commit -m "feat(editor-v2): srccfg StreamLanguage mode"
```

---

## Task 4: Light + dark themes

**Files:**
- Create: `l4d2web/scripts/editor-src/themes.js`

cm6's `EditorView.theme()` produces an Extension. We want two themes
exposed via a `Compartment` so the active one is swappable.

- [ ] **Step 1: Write `themes.js`**

```js
import { EditorView } from "@codemirror/view";

// CSS variables are defined in static/css/tokens.css (light) and the
// `prefers-color-scheme: dark` block. Both themes route through the
// same --cm-* variable names; the OS toggle swaps the underlying values.
//
// Two named themes so we can also force-pick light/dark in tests if needed.

const baseRules = {
  "&": {
    backgroundColor: "var(--cm-bg)",
    color: "var(--cm-fg)",
    fontFamily: "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)",
    fontSize: "14px",
  },
  ".cm-content": { caretColor: "var(--cm-fg)", padding: "8px" },
  ".cm-cursor": { borderLeftColor: "var(--cm-fg)" },
  "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection": {
    backgroundColor: "var(--cm-selection)",
  },
  ".cm-gutters": {
    backgroundColor: "var(--cm-bg)",
    color: "var(--fg-muted, #888)",
    border: "none",
  },
  ".cm-tooltip": {
    backgroundColor: "var(--cm-bg)",
    border: "1px solid var(--border-strong, #444)",
    color: "var(--cm-fg)",
  },
  ".cm-tooltip-autocomplete > ul > li[aria-selected]": {
    backgroundColor: "var(--cm-selection)",
  },
  // Syntax token colors come from highlightStyle below.
};

export const editorLightTheme = EditorView.theme(baseRules, { dark: false });
export const editorDarkTheme = EditorView.theme(baseRules, { dark: true });
```

- [ ] **Step 2: Add the highlight style for syntax tokens**

Append to `themes.js`:

```js
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { tags as t } from "@lezer/highlight";

export const editorHighlightStyle = HighlightStyle.define([
  { tag: t.comment, color: "var(--cm-comment)" },
  { tag: t.string, color: "var(--cm-string)" },
  { tag: t.number, color: "var(--cm-number)" },
  { tag: t.keyword, color: "var(--cm-keyword)" },
  { tag: t.variableName, color: "var(--cm-fg)" },
]);

export const editorHighlighting = syntaxHighlighting(editorHighlightStyle);
```

`@lezer/highlight` is a transitive dep of `@codemirror/language`; no need to add it to `package.json`. esbuild will resolve it.

- [ ] **Step 3: Verify the imports resolve**

From `editor-src/`:
```bash
node --input-type=module -e 'import("./themes.js").then(m => console.log(Object.keys(m).sort()))'
```
Expected: `[ 'editorDarkTheme', 'editorHighlightStyle', 'editorHighlighting', 'editorLightTheme' ]`.

- [ ] **Step 4: Commit**

```bash
git add l4d2web/scripts/editor-src/themes.js
git commit -m "feat(editor-v2): light + dark themes + syntax highlight style"
```

---

## Task 5: Autocomplete completion source

**Files:**
- Create: `l4d2web/scripts/editor-src/autocomplete.js`

- [ ] **Step 1: Write the completion source**

```js
import { autocompletion } from "@codemirror/autocomplete";

const WORD_RE = /[A-Za-z0-9_]{2,}/;

function rank(query, label) {
  const q = query.toLowerCase();
  const l = label.toLowerCase();
  if (l === q) return 0;
  if (l.startsWith(q)) return 1 + l.length;       // shorter prefix matches first
  const i = l.indexOf(q);
  if (i !== -1) return 10000 + i;                 // substring matches after all prefix matches
  return -1;
}

export function vocabCompletions(vocab) {
  // vocab: { cvars: [{name, desc?}, …], commands: [{name, desc?}, …] }
  const entries = [
    ...vocab.cvars.map(e => ({ ...e, kind: "cvar" })),
    ...vocab.commands.map(e => ({ ...e, kind: "command" })),
  ];

  return (context) => {
    const word = context.matchBefore(WORD_RE);
    if (!word || (word.from === word.to && !context.explicit)) return null;
    const q = word.text;

    const scored = [];
    for (const e of entries) {
      const r = rank(q, e.name);
      if (r === -1) continue;
      scored.push([r, e]);
      if (scored.length > 200) break;             // bound work; we cap to 50 below
    }
    scored.sort((a, b) => a[0] - b[0]);
    const options = scored.slice(0, 50).map(([, e]) => ({
      label: e.name,
      info: e.desc || e.kind,
      type: e.kind === "command" ? "function" : "variable",
    }));
    return { from: word.from, options, validFor: WORD_RE };
  };
}

export function autocompleteExtension(vocab) {
  return autocompletion({
    override: [vocabCompletions(vocab)],
    activateOnTyping: true,
    maxRenderedOptions: 8,
  });
}
```

- [ ] **Step 2: Sanity-check exports**

From `editor-src/`:
```bash
node --input-type=module -e 'import("./autocomplete.js").then(m => console.log(Object.keys(m).sort()))'
```
Expected: `[ 'autocompleteExtension', 'vocabCompletions' ]`.

- [ ] **Step 3: Commit**

```bash
git add l4d2web/scripts/editor-src/autocomplete.js
git commit -m "feat(editor-v2): autocomplete completion source"
```

---

## Task 6: Editor entry façade (`editor-entry.js`)

**Files:**
- Modify: `l4d2web/scripts/editor-src/editor-entry.js` (replaces the stub from Task 1)

- [ ] **Step 1: Write the façade**

```js
import { EditorState, Compartment } from "@codemirror/state";
import { EditorView, keymap, lineNumbers, highlightActiveLine } from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { StreamLanguage, indentOnInput, bracketMatching, defaultHighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { closeBrackets, closeBracketsKeymap, completionKeymap } from "@codemirror/autocomplete";
import { shell as shellLegacy } from "@codemirror/legacy-modes/mode/shell";

import { srccfgLanguage } from "./srccfg-mode.js";
import { editorLightTheme, editorDarkTheme, editorHighlighting } from "./themes.js";
import { autocompleteExtension } from "./autocomplete.js";

const bashLanguage = StreamLanguage.define(shellLegacy);

function pickLanguage(name) {
  if (name === "srccfg") return srccfgLanguage;
  if (name === "bash")   return bashLanguage;
  return null;  // "plain" / unknown → no language extension
}

function pickThemeForMatchMedia(mm) {
  return mm.matches ? editorDarkTheme : editorLightTheme;
}

function mount(textarea, { language = "plain", vocab = null } = {}) {
  const langCompartment = new Compartment();
  const themeCompartment = new Compartment();
  const autocompleteCompartment = new Compartment();

  const lang = pickLanguage(language);
  const mm = window.matchMedia("(prefers-color-scheme: dark)");

  const extensions = [
    history(),
    lineNumbers(),
    highlightActiveLine(),
    bracketMatching(),
    closeBrackets(),
    indentOnInput(),
    syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
    editorHighlighting,
    themeCompartment.of(pickThemeForMatchMedia(mm)),
    langCompartment.of(lang ? [lang] : []),
    autocompleteCompartment.of(vocab ? [autocompleteExtension(vocab)] : []),
    keymap.of([
      ...closeBracketsKeymap,
      ...defaultKeymap,
      ...historyKeymap,
      ...completionKeymap,
      indentWithTab,
    ]),
  ];

  const state = EditorState.create({ doc: textarea.value, extensions });
  const view = new EditorView({ state, parent: textarea.parentElement });

  // Insert the editor right before the textarea, then hide the textarea.
  textarea.parentElement.insertBefore(view.dom, textarea);
  textarea.style.display = "none";

  // OS-level theme swap
  const onThemeChange = () => view.dispatch({
    effects: themeCompartment.reconfigure(pickThemeForMatchMedia(mm)),
  });
  mm.addEventListener("change", onThemeChange);

  const controller = {
    getValue: () => view.state.doc.toString(),
    setContent: (text) => {
      view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } });
    },
    setLanguage: (name) => {
      const next = pickLanguage(name);
      view.dispatch({ effects: langCompartment.reconfigure(next ? [next] : []) });
    },
    destroy: () => {
      mm.removeEventListener("change", onThemeChange);
      view.destroy();
      textarea.style.display = "";
    },
  };
  return controller;
}

window.__editor = { mount };
```

- [ ] **Step 2: Smoke import**

From `editor-src/`:
```bash
node --input-type=module -e 'import("./editor-entry.js").catch(e => { console.error(e.message); process.exit(1); })'
```
Expected: errors out on `window is not defined` (Node has no DOM) — but the *imports* must resolve before that error. If you see `Cannot find module …` that's a real failure to fix; the `window is not defined` ReferenceError is fine and proves the module graph resolved.

- [ ] **Step 3: Commit**

```bash
git add l4d2web/scripts/editor-src/editor-entry.js
git commit -m "feat(editor-v2): editor-entry façade wiring all extensions"
```

---

## Task 7: Build script + first successful bundle

**Files:**
- Create: `l4d2web/scripts/build-editor.sh`
- Create: `l4d2web/l4d2web/static/vendor/editor.bundle.js` (committed artifact)
- Create: `l4d2web/l4d2web/static/vendor/editor.bundle.css` (committed artifact)
- Create: `l4d2web/l4d2web/static/vendor/editor.bundle.sha256`
- Create: `l4d2web/l4d2web/static/vendor/README.md`

- [ ] **Step 1: Write `build-editor.sh`**

Path: `l4d2web/scripts/build-editor.sh` — chmod 755.

```bash
#!/usr/bin/env bash
set -euo pipefail

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SRC="$HERE/editor-src"
OUT="$HERE/../l4d2web/static/vendor"

cd "$SRC"
npm ci

npx esbuild editor-entry.js \
  --bundle --minify \
  --format=iife \
  --global-name=__editor_pkg \
  --outfile="$OUT/editor.bundle.js" \
  --metafile=meta.json \
  --loader:.css=text

# (CSS is currently injected by cm6 at runtime via its own DOM machinery;
# we do not need a separate editor.bundle.css for cm6 itself. If a future
# extension requires extracted CSS, switch to --loader:.css=copy.)
# Touch an empty .css so the partial template can <link> it harmlessly.
: > "$OUT/editor.bundle.css"

(cd "$OUT" && sha256sum editor.bundle.js editor.bundle.css > editor.bundle.sha256)

echo "Built $OUT/editor.bundle.js ($(wc -c < "$OUT/editor.bundle.js") bytes)"
```

- [ ] **Step 2: Run it**

From repo root: `./l4d2web/scripts/build-editor.sh`
Expected stdout ends with `Built …/editor.bundle.js (NNNNNN bytes)` where N is roughly 200000–400000 (cm6 minified).

- [ ] **Step 3: Verify the bundle exposes the global**

```bash
node -e 'const fs = require("fs"); const code = fs.readFileSync("l4d2web/l4d2web/static/vendor/editor.bundle.js", "utf8"); eval(code); console.log(typeof __editor_pkg, typeof window.__editor.mount);'
```
You'll need a `global.window = global` shim — instead run:
```bash
node -e 'global.window = global; global.matchMedia = () => ({matches:false, addEventListener(){}, removeEventListener(){}}); eval(require("fs").readFileSync("l4d2web/l4d2web/static/vendor/editor.bundle.js","utf8")); console.log(typeof window.__editor, typeof window.__editor.mount);'
```
Expected: `object function`.

- [ ] **Step 4: Write the vendor README**

`l4d2web/l4d2web/static/vendor/README.md`:

```markdown
# Editor bundle vendor README

`editor.bundle.js` is a pre-built IIFE produced by esbuild from
`l4d2web/scripts/editor-src/`. It exposes `window.__editor.mount(textarea, opts)`.

## Rebuild

From repo root:

```
./l4d2web/scripts/build-editor.sh
```

This runs `npm ci` inside `editor-src/` then `npx esbuild`. The
output overwrites `editor.bundle.js` and `editor.bundle.css` in this
directory and refreshes `editor.bundle.sha256`.

## Pinned dependencies

See `l4d2web/scripts/editor-src/package.json` for semver ranges and
`package-lock.json` for the exact resolved versions. Run
`npm outdated` inside `editor-src/` to see upgrade candidates.

## Integrity

`editor.bundle.sha256` contains the hashes of the committed bundle.
If the bundle drifts from this hash in CI / review, the artifact was
rebuilt without committing the updated bundle.
```

- [ ] **Step 5: Commit**

```bash
git add l4d2web/scripts/build-editor.sh \
        l4d2web/l4d2web/static/vendor/editor.bundle.js \
        l4d2web/l4d2web/static/vendor/editor.bundle.css \
        l4d2web/l4d2web/static/vendor/editor.bundle.sha256 \
        l4d2web/l4d2web/static/vendor/README.md
git commit -m "feat(editor-v2): build script + first bundle output"
```

---

## Task 8: tokens.css syntax variables + editor.css bridge

**Files:**
- Modify: `l4d2web/l4d2web/static/css/tokens.css` (add `--syntax-*` + `--cm-*` in default and `prefers-color-scheme: dark` blocks)
- Create: `l4d2web/l4d2web/static/css/editor.css`

- [ ] **Step 1: Read the current tokens.css to find the right insertion points**

```bash
cat l4d2web/l4d2web/static/css/tokens.css
```

You're looking for the `:root` block (default = light palette) and the
`@media (prefers-color-scheme: dark)` block at line 38.

- [ ] **Step 2: Add light-mode variables to the `:root` block**

Insert before the closing `}` of the default `:root` block. Pick
existing variable names where they exist (e.g. `--fg-primary`,
`--bg-surface`); otherwise use literal colors.

```css
/* Editor (CodeMirror 6) palette — light. */
--cm-bg: var(--bg-surface, #ffffff);
--cm-fg: var(--fg-primary, #222);
--cm-selection: rgba(60, 130, 220, 0.2);
--syntax-keyword: #cc4488;
--syntax-string:  #2f8b3a;
--syntax-comment: #888;
--syntax-number:  #884488;
--cm-keyword: var(--syntax-keyword);
--cm-string:  var(--syntax-string);
--cm-comment: var(--syntax-comment);
--cm-number:  var(--syntax-number);
```

- [ ] **Step 3: Add dark-mode overrides inside the `@media (prefers-color-scheme: dark)` block**

```css
--cm-bg: var(--bg-surface, #181a1c);
--cm-fg: var(--fg-primary, #e8e8e8);
--cm-selection: rgba(120, 170, 255, 0.25);
--syntax-keyword: #ff80c0;
--syntax-string:  #87d96a;
--syntax-comment: #888;
--syntax-number:  #c890ff;
```

(`--cm-*` aliases re-bind through `tokens.css` cascade — no need to
restate them in the dark block; the underlying `--syntax-*` change
propagates.)

- [ ] **Step 4: Write `editor.css`**

```css
/* Bridges cm6 internal classes to the variables declared in tokens.css.
 * Most styling is done in themes.js; this file scopes the editor shell
 * itself + layout sizing the per-call-site templates expect. */

.editor-shell, .cm-editor {
  border: 1px solid var(--border, #ccc);
  border-radius: 4px;
  min-height: 8em;
  font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
}

.cm-editor.cm-focused {
  outline: 2px solid var(--focus-ring, #4a90e2);
  outline-offset: -2px;
}

textarea[data-editor-language] + .cm-editor {
  width: 100%;
}
```

- [ ] **Step 5: Commit**

```bash
git add l4d2web/l4d2web/static/css/tokens.css l4d2web/l4d2web/static/css/editor.css
git commit -m "feat(editor-v2): tokens.css syntax vars + editor.css shell"
```

---

## Task 9: Editor.js glue (mount loop, submit-capture, `__filesEditor` alias)

**Files:**
- Create: `l4d2web/l4d2web/static/js/editor.js`

- [ ] **Step 1: Write `editor.js`**

```js
// Un-bundled. Driven by data-editor-language attrs on <textarea>.
// Mounts cm6 (from editor.bundle.js exporting window.__editor),
// installs one capture-phase submit handler per <form>, and exposes
// a named alias for the files-editor modal.
(function () {
  "use strict";
  if (!window.__editor || typeof window.__editor.mount !== "function") {
    return;  // bundle didn't load — graceful no-JS fallback
  }

  let vocabPromise = null;
  function loadSrccfgVocab() {
    if (!vocabPromise) {
      vocabPromise = fetch("/static/data/srccfg-vocab.json", { credentials: "same-origin" })
        .then(r => r.ok ? r.json() : Promise.reject(new Error("vocab fetch failed: " + r.status)))
        .catch(err => { console.warn("[editor] vocab load failed", err); return null; });
    }
    return vocabPromise;
  }

  function resolveAutoLanguage(filenameInput) {
    const name = (filenameInput && filenameInput.value || "").toLowerCase();
    if (name.endsWith(".cfg")) return "srccfg";
    if (name.endsWith(".sh"))  return "bash";
    return "plain";
  }

  async function mountOne(textarea) {
    let lang = textarea.getAttribute("data-editor-language") || "plain";
    let filenameInput = null;
    let dropdown = null;
    if (lang === "auto") {
      const modal = textarea.closest(".files-editor") || document;
      filenameInput = modal.querySelector("[data-editor-filename]");
      dropdown = modal.querySelector("[data-editor-language-select]");
      lang = resolveAutoLanguage(filenameInput);
    }

    const vocab = (lang === "srccfg") ? await loadSrccfgVocab() : null;
    const controller = window.__editor.mount(textarea, { language: lang, vocab });

    // Submit-time copy bridge
    const form = textarea.closest("form");
    if (form && !form.__editorSubmitBound) {
      form.__editorSubmitBound = true;
      form.addEventListener("submit", () => {
        for (const ta of form.querySelectorAll("textarea[data-editor-language]")) {
          if (ta.__editorController) ta.value = ta.__editorController.getValue();
        }
      }, true /* capture phase */);
    }
    textarea.__editorController = controller;

    // Files-modal hooks
    if (textarea.classList.contains("files-editor-content")) {
      window.__filesEditor = controller;
      if (dropdown) {
        dropdown.addEventListener("change", () => {
          const v = dropdown.value;
          controller.setLanguage(v === "auto" ? resolveAutoLanguage(filenameInput) : v);
        });
      }
      if (filenameInput) {
        filenameInput.addEventListener("input", () => {
          if (!dropdown || dropdown.value === "auto") {
            controller.setLanguage(resolveAutoLanguage(filenameInput));
          }
        });
      }
    }
  }

  function init() {
    for (const ta of document.querySelectorAll("textarea[data-editor-language]")) {
      mountOne(ta).catch(err => console.error("[editor] mount failed", err));
    }
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", init);
  } else {
    init();
  }
})();
```

- [ ] **Step 2: Lint-by-eye for one thing**

Confirm the submit listener is capture-phase (`true` argument) and runs *before* the browser default form serialization. It must be capture-phase: a bubble-phase listener runs after the browser has already read `textarea.value` for the POST body.

- [ ] **Step 3: Commit**

```bash
git add l4d2web/l4d2web/static/js/editor.js
git commit -m "feat(editor-v2): editor.js glue (mount, submit-capture, files alias)"
```

---

## Task 10: `_editor_assets.html` Jinja partial

**Files:**
- Create: `l4d2web/l4d2web/templates/_editor_assets.html`

- [ ] **Step 1: Write the partial**

```html
{# Editor assets — include on any page that mounts a <textarea data-editor-language>. #}
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/editor.bundle.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/editor.css') }}">
<script src="{{ url_for('static', filename='vendor/editor.bundle.js') }}" nonce="{{ g.csp_nonce }}" defer></script>
<script src="{{ url_for('static', filename='js/editor.js') }}" nonce="{{ g.csp_nonce }}" defer></script>
```

`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
<textarea name="config" rows="8" spellcheck="false">{{ config_lines | join('\n') }}</textarea>
```
Change to:
```html
<textarea name="config" rows="8" spellcheck="false" data-editor-language="srccfg">{{ config_lines | join('\n') }}</textarea>
```

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 `</body>` 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 `<select data-editor-language-select>`
  with options `auto, srccfg, bash, plain`, and add `data-editor-filename`
  to the existing filename input.
- Include `_editor_assets.html` partial.

Re-run, watch pass.

- [ ] **Step 6: Re-run the form-contract tests**

Verify Task 11's tests still pass with the new attrs:

`uv run pytest tests/test_blueprints.py tests/test_script_overlay_routes.py -v`
Expected: all green, including the new markup tests.

- [ ] **Step 7: Commit**

```bash
git add l4d2web/l4d2web/templates/blueprint_detail.html \
        l4d2web/l4d2web/templates/overlay_detail.html \
        l4d2web/tests/test_blueprints.py \
        l4d2web/tests/test_script_overlay_routes.py
git commit -m "feat(editor-v2): wire data-editor-language attrs into 3 textareas"
```

---

## Task 13: files-overlay.js bridge swap

**Files:**
- Modify: `l4d2web/l4d2web/static/js/files-overlay.js` (5 reads + 3 writes)

The files-editor modal currently reads/writes the textarea directly.
Switch every site to the editor controller via the `window.__filesEditor`
alias from Task 9.

- [ ] **Step 1: Define a helper at the top of files-overlay.js**

Near the top of the module (after the existing IIFE opener):

```js
function getEditorValue() {
  return (window.__filesEditor && window.__filesEditor.getValue)
    ? window.__filesEditor.getValue()
    : editorEls.contentBox.value;  // no-JS / bundle-failed fallback
}
function setEditorValue(text) {
  if (window.__filesEditor && window.__filesEditor.setContent) {
    window.__filesEditor.setContent(text);
  } else {
    editorEls.contentBox.value = text;
  }
}
```

(Place these in scope where `editorEls` is accessible.)

- [ ] **Step 2: Replace each read**

At lines 289, 464, 479, 494, 511: `editorEls.contentBox.value` →
`getEditorValue()`.

- [ ] **Step 3: Replace each write**

At lines 345, 378, 385: `editorEls.contentBox.value = X` →
`setEditorValue(X)`.

- [ ] **Step 4: Verify no remaining direct accesses**

```bash
rg -n "contentBox\.value" l4d2web/l4d2web/static/js/files-overlay.js
```
Expected: only the two fallback paths inside `getEditorValue` /
`setEditorValue`.

- [ ] **Step 5: Local manual smoke**

```bash
./scripts/dev-server.py
```
Visit `http://127.0.0.1:5051/login`, log in as `dev`/`devdevdev`,
visit `/overlays/2` (files overlay), open `test.cfg` in the editor.
Verify:
- Content loads.
- Editing produces highlighted srccfg tokens.
- Save round-trips via `fetch('/files/save')` (check Network tab).
- Switching the language `<select>` 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 `<select>` switches to bash and re-highlights; Save round-trips via `/files/save` |

Toggle the OS color scheme during the smoke; verify the editor re-skins.

- [ ] **Step 4: Run the full test matrix**

```bash
cd l4d2web && uv run pytest                            # fast suite
cd l4d2web && uv run pytest -m e2e                     # e2e suite
```
Both green.

- [ ] **Step 5: Commit**

```bash
git add AGENTS.md
git commit -m "docs(editor-v2): AGENTS.md update + final smoke pass"
```

---

## Self-review (done by plan author)

**1. Spec coverage:**

| Spec section | Covered by |
|---|---|
| §1 Context / handoff link | (referenced in plan header) |
| §2 Goal | Plan goal line |
| §3 Architecture / form-bridge | Tasks 6, 9 (façade + submit-capture) |
| §3 Subsystems table | Tasks 1–10 (one per row) |
| §4 Call sites | Task 12 (template wiring) |
| §5 Editor module contract | Task 6 (editor-entry.js) |
| §6 Languages | Tasks 3 (srccfg), 6 (bash via legacy shell) |
| §7 Autocomplete | Task 5 |
| §8 Vocabulary | Task 2 |
| §9 Theme (light/dark via prefers-color-scheme) | Tasks 4, 6 (matchMedia compartment), 8 (CSS vars) |
| §10 Build pipeline | Tasks 1, 7 |
| §11 CSP & asset layout | Task 10 (`_editor_assets.html` partial with nonces) |
| §12 Files touched | All tasks |
| §13 Test strategy | Tasks 11, 12, 14 |
| §14 Verification | Task 15 |
| §15 Out of scope | Honored (no line-numbers extension, no search, etc.) |

**2. Placeholder scan:** none — every code/CSS/HTML block is concrete.

**3. Type consistency:** controller shape (`getValue / setContent / setLanguage / destroy`) is consistent across Tasks 6, 9, 13. `__editor.mount` signature is consistent. The named alias is consistently `window.__filesEditor`.

**4. Risk callouts left to executing engineer's judgement:**
- Exact resolved cm6 minor versions from `npm install` may differ from the `^6.x.y` ranges in `package.json` — that's the point of committing the lockfile. The bundle size + bundle hash will be deterministic per lockfile.
- The line numbers cited for `files-overlay.js` (289, 464, …) are based on the current HEAD. If a prior unrelated commit shifts them, re-run `rg -n "contentBox\.value" l4d2web/l4d2web/static/js/files-overlay.js` and edit those sites.
- If the actual `:root` block in `tokens.css` doesn't have `--bg-surface` / `--fg-primary`, fall back to the literal colors named in Task 8's CSS via the var()'s default branch — already wired in.

---

## Execution handoff

Plan complete. Spec at `docs/superpowers/specs/2026-05-17-textarea-editor-v2-design.md`; plan at `docs/superpowers/plans/2026-05-17-textarea-editor-v2.md`. Two execution options:

1. **Subagent-Driven** (recommended) — fresh subagent per task; review between tasks; uses `superpowers:subagent-driven-development`.
2. **Inline Execution** — execute tasks in this session via `superpowers:executing-plans` with checkpoints.
