# Textarea Code Editor 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:** Upgrade the blueprint config, overlay script, and files-editor textareas with a reusable vanilla-JS code editor that does syntax highlighting and identifier autocomplete.

**Architecture:** One widget (`editor.js`) mounts on any `<textarea data-editor-language>`. The textarea stays in the DOM as the value carrier; a sibling `contenteditable` mirrors content back on every input. CodeJar handles the editor shell, Prism handles highlighting, a small custom popup handles autocomplete. No bundler, no build step — everything self-hosted under `/static/`.

**Tech Stack:** Vanilla JS (ES2020), Prism.js 1.x (vendored), CodeJar 4.x (vendored), Jinja, Flask, pytest, Playwright.

**Reference spec:** `docs/superpowers/specs/2026-05-16-textarea-code-editor-design.md`

---

## File Structure

| File | Action | Responsibility |
|---|---|---|
| `l4d2web/l4d2web/static/vendor/prism.js` | Create (vendor) | Prism core + `clike` + `bash` (custom build from prismjs.com/download) |
| `l4d2web/l4d2web/static/vendor/prism.css` | Create (vendor) | Prism default theme |
| `l4d2web/l4d2web/static/vendor/codejar.js` | Create (vendor) | CodeJar editor shell (UMD release) |
| `l4d2web/l4d2web/static/vendor/README.md` | Create | Pinned URLs + versions + SHA256 for each vendored asset |
| `l4d2web/l4d2web/static/js/srccfg-grammar.js` | Create | `Prism.languages.srccfg` token regexes |
| `l4d2web/l4d2web/static/js/editor.js` | Create | Widget: mount, sync, autocomplete popup, language switch, auto-detection |
| `l4d2web/l4d2web/static/css/editor.css` | Create | `.editor-shell`, `.editor-code`, `.editor-popup` styles |
| `l4d2web/l4d2web/static/data/srccfg-vocab.json` | Create | Curated cvars/commands for autocomplete |
| `l4d2web/l4d2web/templates/_editor_assets.html` | Create | Jinja partial: link/script tags with nonces |
| `l4d2web/l4d2web/templates/blueprint_detail.html` | Modify (~line 52, end of `content` block) | Add `data-editor-language="srccfg"`; include partial |
| `l4d2web/l4d2web/templates/overlay_detail.html` | Modify (~lines 25, 178, end of `content` block) | Bash editor for script form; auto editor + dropdown for files-editor; include partial |
| `l4d2web/l4d2web/static/js/files-overlay.js` | Modify (~lines 345, 385) | Replace direct `editorEls.contentBox.value = …` with `editor.setValue(…)` + `editor.setLanguage(…)` |
| `l4d2web/pyproject.toml` | Modify | Add `playwright` to dev deps |
| `l4d2web/tests/e2e/__init__.py` | Create | Marker file |
| `l4d2web/tests/e2e/conftest.py` | Create | Pytest fixture booting Flask app on an ephemeral port |
| `l4d2web/tests/e2e/test_editor.py` | Create | Playwright test: type `sv_che`, accept popup, assert textarea value |
| `l4d2web/tests/test_blueprints.py` | Modify | Add two form-contract tests for the upgraded textarea |
| `l4d2web/tests/test_overlay_creation.py` | Modify (or wherever overlay-script form is tested) | Add one form-contract test for bash editor presence |
| `AGENTS.md` (repo root) | Modify | One-line note: run `playwright install chromium` to set up e2e |

**Untouched by design:**
- `l4d2web/l4d2web/routes/blueprint_routes.py` — form contract unchanged
- `l4d2web/l4d2web/app.py` — CSP already permits nonce'd scripts
- The files-overlay save path (`/files/save`) — JSON shape unchanged

---

## Task 1: Vendor Prism + CodeJar with provenance

**Files:**
- Create: `l4d2web/l4d2web/static/vendor/prism.js`
- Create: `l4d2web/l4d2web/static/vendor/prism.css`
- Create: `l4d2web/l4d2web/static/vendor/codejar.js`
- Create: `l4d2web/l4d2web/static/vendor/README.md`

- [ ] **Step 1: Assemble the Prism bundle via curl**

Prism ships per-component files at `cdn.jsdelivr.net/npm/prismjs@1.29.0/`. We concatenate three components — `core` (required), `clike` (bash depends on it), `bash` — into a single self-contained `prism.js`. Theme CSS is grabbed separately.

```bash
mkdir -p l4d2web/l4d2web/static/vendor
VER=1.29.0
BASE=https://cdn.jsdelivr.net/npm/prismjs@${VER}

# Concatenate core + clike + bash into one file, in the required load order.
{
  echo "/* Prism v${VER} — core + clike + bash, assembled from ${BASE}/components/ */"
  curl -fsSL "${BASE}/components/prism-core.min.js"
  echo  # newline separator between components
  curl -fsSL "${BASE}/components/prism-clike.min.js"
  echo
  curl -fsSL "${BASE}/components/prism-bash.min.js"
} > l4d2web/l4d2web/static/vendor/prism.js

# Theme CSS (the default "prism" theme).
curl -fsSL -o l4d2web/l4d2web/static/vendor/prism.css \
  "${BASE}/themes/prism.min.css"
```

Verify:

```bash
ls -la l4d2web/l4d2web/static/vendor/prism.{js,css}
# Prism's minified bundle renames `Prism` → `e`, so grep for the
# bash-specific shebang regex that ships in the bash grammar instead.
grep -cE 'shebang|languages\.bash' l4d2web/l4d2web/static/vendor/prism.js
```

Expected: `prism.js` around 15–25 KB; `prism.css` around 1–3 KB. Grep count ≥1 (confirming the bash grammar got included — both `shebang` token and the language attachment are present in the minified bundle).

- [ ] **Step 2: Download CodeJar from unpkg**

CodeJar publishes a browser-ready bundle to npm. unpkg/jsdelivr serve it directly:

```bash
VER=4.0.0
curl -fsSL -o l4d2web/l4d2web/static/vendor/codejar.js \
  "https://cdn.jsdelivr.net/npm/codejar@${VER}/dist/codejar.js"
```

Inspect the first 5 lines to confirm it's a valid JS module:

```bash
head -5 l4d2web/l4d2web/static/vendor/codejar.js
```

If the file uses ES module `export` syntax (`export function CodeJar` or `export { CodeJar }`), strip the export keyword and append a global shim so `<script>` tags can load it directly:

```bash
# Strip ESM exports, then expose CodeJar on window.
sed -i.bak \
  -e 's/^export function /function /' \
  -e 's/^export { CodeJar }.*$//' \
  -e 's/^export { CodeJar as default }.*$//' \
  l4d2web/l4d2web/static/vendor/codejar.js
rm -f l4d2web/l4d2web/static/vendor/codejar.js.bak

cat >> l4d2web/l4d2web/static/vendor/codejar.js <<'EOF'

// Browser global shim: surface CodeJar on window so non-module
// <script> tags can call it.
window.CodeJar = CodeJar;
EOF
```

If the CDN serves a UMD/IIFE bundle that already exposes `window.CodeJar`, the sed lines are no-ops and the final `window.CodeJar = CodeJar` will fail. In that case, open the file with `head -20` to confirm CodeJar is already on window, and either remove the shim or wrap it in a `typeof CodeJar !== 'undefined'` check. **If unsure, BLOCK and report what the file actually contains so the controller can advise.**

- [ ] **Step 3: Record SHA256s and source URLs in README.md**

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

```markdown
# Vendored static assets

All third-party JS/CSS shipped under `/static/vendor/` is committed
verbatim from the upstream releases below. The strict CSP
(`default-src 'self'`) means we cannot load these from CDNs.

| File | Upstream | Version | SHA256 |
|---|---|---|---|
| `prism.js` | jsdelivr concat: prism-core.min.js + prism-clike.min.js + prism-bash.min.js | v1.29.0 | `<sha>` |
| `prism.css` | https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism.min.css | v1.29.0 | `<sha>` |
| `codejar.js` | https://cdn.jsdelivr.net/npm/codejar@4.0.0/codejar.js + ESM-strip + browser-global shim | v4.0.0 | `<sha>` |

## Regenerating

- **Prism:** Re-run the three-component concat in Task 1 Step 1 of
  `docs/superpowers/plans/2026-05-16-textarea-code-editor.md` with
  an updated `VER`, then re-download the theme CSS.
- **CodeJar:** Re-download from jsdelivr per the same plan's Task 1
  Step 2, strip ESM exports, re-append the `window.CodeJar` shim.

Bump the version + SHA columns in this table after any update.
```

Then fill in the SHA256s:

```bash
cd l4d2web/l4d2web/static/vendor
for f in prism.js prism.css codejar.js; do
  echo "$f: $(shasum -a 256 "$f" | cut -d' ' -f1)"
done
```

Paste each SHA into the table.

- [ ] **Step 4: Verify the asset files load**

```bash
ls -la l4d2web/l4d2web/static/vendor/
```

Expected output includes `codejar.js`, `prism.css`, `prism.js`, `README.md`. File sizes: prism.js around 20–30 KB; prism.css around 1–3 KB; codejar.js around 3–5 KB.

- [ ] **Step 5: Commit**

```bash
git add l4d2web/l4d2web/static/vendor/
git commit -m "vendor(editor): pin Prism v1.29.0 + CodeJar v4.0.0

Self-host the editor dependencies under /static/vendor/ since the strict
CSP forbids CDN loading. README records source URLs, versions, and
SHA256s for each file."
```

---

## Task 2: srccfg Prism grammar

**Files:**
- Create: `l4d2web/l4d2web/static/js/srccfg-grammar.js`

The grammar defines five token classes for Source-engine `.cfg` syntax: comment, string, number, keyword (`exec`, `alias`, `bind`), and a generic identifier. Prism applies CSS classes like `token comment`, `token string`, etc. — Task 5 styles them.

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

Create `l4d2web/l4d2web/static/js/srccfg-grammar.js`:

```js
// Prism grammar for Source-engine config files (server.cfg-style).
// Tokens: comment, string, number, keyword, identifier. Purely visual —
// no semantic validation of cvars or values.
(function (Prism) {
  if (!Prism) return;
  Prism.languages.srccfg = {
    comment: /\/\/.*/,
    string: {
      pattern: /"(?:\\.|[^"\\])*"/,
      greedy: true,
    },
    keyword: /\b(?:exec|alias|bind|unbind|toggle)\b/,
    number: /\b\d+(?:\.\d+)?\b/,
    identifier: /\b[a-zA-Z_][a-zA-Z0-9_]*\b/,
    operator: /[+\-;]/,
  };
})(typeof window !== "undefined" ? window.Prism : undefined);
```

- [ ] **Step 2: Manual verification (deferred until Task 6 is wired)**

This file is verified end-to-end once Task 6 renders a blueprint detail page with the editor mounted. For now, sanity-check it parses by opening any HTML page that loads prism.js + this file (or test in Node):

```bash
node -e "
const window = {};
const fs = require('fs');
eval(fs.readFileSync('l4d2web/l4d2web/static/vendor/prism.js', 'utf8'));
eval(fs.readFileSync('l4d2web/l4d2web/static/js/srccfg-grammar.js', 'utf8'));
console.log(Object.keys(window.Prism.languages.srccfg));
"
```

Expected output: `[ 'comment', 'string', 'keyword', 'number', 'identifier', 'operator' ]`.

- [ ] **Step 3: Commit**

```bash
git add l4d2web/l4d2web/static/js/srccfg-grammar.js
git commit -m "feat(editor): add Prism grammar for Source-engine .cfg syntax

Five token classes (comment, string, keyword, number, identifier) plus
operators. Purely visual highlighting; no semantic validation of cvar
names or values."
```

---

## Task 3: Editor stylesheet

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

The widget needs to visually replace the textarea without layout shift. The contenteditable inherits the textarea's monospace look from `tokens.css`. Popup is absolutely positioned near the caret.

- [ ] **Step 1: Inspect existing textarea styling**

```bash
grep -n "textarea" l4d2web/l4d2web/static/css/components.css | head -5
grep -n "monospace\|--font-mono\|--color-" l4d2web/l4d2web/static/css/tokens.css | head -10
```

Note the font-family, color, border, and background tokens that textareas use today. Use those same tokens in editor.css so the upgraded surface matches.

- [ ] **Step 2: Write editor.css**

Create `l4d2web/l4d2web/static/css/editor.css`:

```css
/* Code editor widget — paired with editor.js. Mounted as a sibling of
   any <textarea data-editor-language>. The textarea is hidden but stays
   in the DOM for form submission and JS .value reads. */

/* Token swaps from spec to match tokens.css:
   --color-fg       → --color-text    (text color token)
   --color-bg-input → --color-surface (textarea uses --color-surface)
   --radius-input   → --radius-s      (closest available radius token)
   --color-bg-popover → --color-surface (surface layer for overlays)
*/

.editor-shell {
  position: relative;
  width: 100%;
}

.editor-code {
  display: block;
  width: 100%;
  min-height: 6em;
  padding: var(--space-s) var(--space-m);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.875rem;
  line-height: 1.5;
  color: var(--color-text, #e6e6e6);
  background: var(--color-surface, #1b1b1b);
  border: 1px solid var(--color-border, #333);
  border-radius: var(--radius-s, 4px);
  white-space: pre-wrap;
  word-break: break-word;
  overflow: auto;
  outline: none;
}

.editor-code:focus {
  border-color: var(--color-focus, #6ab0ff);
}

/* Prism token colors — override defaults to match the site palette.
   --color-string / --color-keyword / --color-number / --color-bg-popover-active
   are added to tokens.css in both :root and the dark-mode block, so the
   editor inherits the site's light/dark theming without per-token fallbacks. */
.editor-code .token.comment    { color: var(--color-muted, #888); font-style: italic; }
.editor-code .token.string     { color: var(--color-string); }
.editor-code .token.keyword    { color: var(--color-keyword); font-weight: 600; }
.editor-code .token.number     { color: var(--color-number); }
.editor-code .token.operator   { color: var(--color-muted, #888); }
.editor-code .token.identifier { color: inherit; }

/* Autocomplete popup. */
.editor-popup {
  position: absolute;
  z-index: 1000;
  max-height: 14em;
  overflow-y: auto;
  min-width: 14em;
  margin: 0;
  padding: 0.25em 0;
  list-style: none;
  font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, monospace);
  font-size: 0.9em;
  background: var(--color-surface, #222);
  border: 1px solid var(--color-border, #333);
  border-radius: var(--radius-s, 4px);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}

.editor-popup-item {
  padding: 0.25em 0.75em;
  cursor: pointer;
  white-space: nowrap;
}

.editor-popup-item.is-active {
  background: var(--color-bg-popover-active);
}

.editor-popup-item .name { color: var(--color-text, #e6e6e6); }
.editor-popup-item .desc { color: var(--color-muted, #888); margin-left: 0.5em; }

/* Files-editor language dropdown. */
.editor-language-select {
  margin-left: 0.5em;
}
```

The token-swap comment at the top records the four `tokens.css` reconciliations applied during the original audit pass. The five-line block following the Prism-token comment block also requires the matching token additions to `tokens.css` (both `:root` and `@media (prefers-color-scheme: dark)`): see the immediately following sub-step.

**Step 2b: Add the syntax-highlighting tokens to `tokens.css`**

```css
/* Inside :root (light mode) */
--color-string: #0a3069;          /* dark blue */
--color-keyword: #cf222e;         /* red */
--color-number: #0550ae;          /* medium blue */
--color-bg-popover-active: #e5e7eb;  /* light gray — visible on white surface */

/* Inside @media (prefers-color-scheme: dark) */
--color-string: #a5d6ff;          /* light blue */
--color-keyword: #ff7b72;         /* salmon */
--color-number: #79c0ff;          /* light blue, different hue from string */
--color-bg-popover-active: #374151;  /* lifted gray on dark surface */
```

These are GitHub-style code-syntax colors tuned to ≥4.5:1 contrast on each theme's surface. Confirm placement matches the file's existing block structure (alphabetical-within-block in the current file).

- [ ] **Step 3: Commit**

```bash
git add l4d2web/l4d2web/static/css/editor.css
git commit -m "style(editor): add stylesheet for editor shell + Prism tokens + popup

Defines .editor-shell, .editor-code, .editor-popup. Reuses tokens.css
variables where present so the editor matches the site palette."
```

---

## Task 4: Editor widget core (mount + textarea sync, no autocomplete yet)

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

The widget mounts on every `<textarea data-editor-language>`, hides the textarea, creates a sibling contenteditable, mounts CodeJar with Prism highlighting, and pipes content back to the textarea on every input. Autocomplete is added in Task 9; this task lands a working "highlight as you type" experience.

- [x] **Step 1: Write `editor.js` (consolidated final version)**

Create `l4d2web/l4d2web/static/js/editor.js`:

Key design decisions baked into the final file (both the initial skeleton and the `setLanguage` fix merged into one):

- `findFilenameInput` is hoisted above `mount` so the initial `language` resolution can reference it without a forward-reference hazard.
- `attachOnUpdate(jarInstance)` is a small helper called at construction time AND inside `setLanguage`'s remount, avoiding a duplicated callback body.
- All method bodies in `instance` use `instance.jar` (not the captured `jar` variable) so `setValue`/`getValue`/`destroy` always operate on the *current* jar after a `setLanguage` swap.
- `setLanguage` uses tear-down-and-remount (not `updateOptions`/`code.__highlighter`) because CodeJar captures its highlight callback by closure at construction time and provides no API to swap it on a live instance.

```js
// Code editor widget. Mounts on any <textarea data-editor-language>.
// The textarea stays in the DOM (display:none) and the widget mirrors
// content back into it on every input — form submission and JS code
// that reads textarea.value (e.g. files-overlay.js) keep working.
//
// Public per-instance API (attached to the textarea as ._codeEditor):
//   - setValue(text)
//   - setLanguage(name)   // "srccfg" | "bash" | "plain" | "auto"
//   - getValue()
//   - destroy()
(function () {
  "use strict";

  const LANG_BY_EXT = {
    cfg: "srccfg",
    sh: "bash",
    bash: "bash",
  };

  function resolveAutoLanguage(filename) {
    if (!filename) return "plain";
    const m = /\.([a-zA-Z0-9]+)$/.exec(filename);
    if (!m) return "plain";
    return LANG_BY_EXT[m[1].toLowerCase()] || "plain";
  }

  function highlightFor(lang) {
    return function (editorEl) {
      if (lang === "plain" || !window.Prism || !window.Prism.languages[lang]) {
        // Plain mode or grammar missing: leave textContent alone.
        editorEl.innerHTML = editorEl.textContent
          .replace(/&/g, "&amp;")
          .replace(/</g, "&lt;");
        return;
      }
      window.Prism.highlightElement(editorEl);
    };
  }

  // For "auto" language: look for a filename input inside the nearest
  // enclosing dialog or .modal. Returns the <input> or null.
  // Intentionally does NOT walk up to <body>: an "auto" textarea
  // outside a modal scope degrades to "plain" rather than picking up
  // some unrelated .files-editor-filename elsewhere in the document.
  function findFilenameInput(textarea) {
    const scope = textarea.closest("dialog, .modal");
    if (!scope) return null;
    return scope.querySelector(".files-editor-filename");
  }

  function mount(textarea) {
    if (textarea._codeEditor) return textarea._codeEditor;

    const requested = textarea.dataset.editorLanguage || "plain";
    let language =
      requested === "auto"
        ? resolveAutoLanguage(findFilenameInput(textarea)?.value)
        : requested;

    // Build the visible editor.
    const shell = document.createElement("div");
    shell.className = "editor-shell";
    const code = document.createElement("code");
    code.className = "editor-code language-" + language;
    code.setAttribute("contenteditable", "true");
    code.setAttribute("spellcheck", "false");
    code.textContent = textarea.value;
    shell.appendChild(code);
    textarea.parentNode.insertBefore(shell, textarea);
    textarea.style.display = "none";

    // CodeJar mounts on the contenteditable and re-runs the highlighter
    // on each input while preserving caret position.
    const jar = window.CodeJar(code, highlightFor(language), { tab: "  " });

    function attachOnUpdate(jarInstance) {
      jarInstance.onUpdate(function (value) {
        // Mirror back to the underlying textarea so form POST and any
        // .value readers see the current content.
        textarea.value = value;
        textarea.dispatchEvent(new Event("input", { bubbles: true }));
      });
    }

    attachOnUpdate(jar);

    const instance = {
      textarea,
      shell,
      code,
      jar,
      language,
      setValue: function (text) {
        // updateCode does not invoke the onUpdate mirror, so fire the
        // same textarea sync + input event here for consistency. Any
        // listener watching the textarea sees external setValue calls
        // (e.g. files-overlay loading a file into the modal) the same
        // way it sees user typing.
        instance.jar.updateCode(text);
        textarea.value = text;
        textarea.dispatchEvent(new Event("input", { bubbles: true }));
      },
      getValue: function () {
        return instance.jar.toString();
      },
      setLanguage: function (name) {
        const next =
          name === "auto"
            ? resolveAutoLanguage(findFilenameInput(textarea)?.value)
            : name;
        if (next === instance.language) return;
        // CodeJar captures its highlight callback by closure at
        // construction time — there is no API to swap it on a live
        // instance. Tear down and remount with the new highlighter.
        // Caret position is lost on switch; acceptable since this is
        // triggered by the user clicking the language dropdown.
        const currentText = instance.jar.toString();
        instance.jar.destroy();
        instance.language = next;
        code.className = "editor-code language-" + next;
        code.textContent = currentText;
        instance.jar = window.CodeJar(code, highlightFor(next), { tab: "  " });
        attachOnUpdate(instance.jar);
      },
      destroy: function () {
        instance.jar.destroy();
        shell.remove();
        textarea.style.display = "";
        delete textarea._codeEditor;
      },
    };

    textarea._codeEditor = instance;
    return instance;
  }

  function mountAll(root) {
    const scope = root || document;
    scope.querySelectorAll("textarea[data-editor-language]").forEach(mount);
  }

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

  // Re-export for callers that need to mount editors created later
  // (the files-editor modal exists in the static DOM but is only
  // shown after user interaction — initial mount is correct, but
  // exposing this hook lets future code mount dynamically-inserted
  // editors if needed).
  window.l4d2Editor = { mount, mountAll };
})();
```

- **Manual verification note:** No smoke test in this task — first live render happens in Task 6 when the blueprint config textarea gets `data-editor-language="srccfg"` and the asset partial is included.

---

## Task 5: Editor assets Jinja partial

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

Three pages need the same five script/link tags. Centralise into a partial.

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

Create `l4d2web/l4d2web/templates/_editor_assets.html`:

```jinja
{# Editor asset bundle — include on any page that mounts a
   <textarea data-editor-language>. Order matters: prism + codejar load
   first, then the srccfg grammar registers itself on window.Prism, then
   editor.js scans the DOM and mounts. #}
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/prism.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/editor.css') }}">
<script src="{{ url_for('static', filename='vendor/prism.js') }}" defer nonce="{{ g.csp_nonce }}"></script>
<script src="{{ url_for('static', filename='vendor/codejar.js') }}" defer nonce="{{ g.csp_nonce }}"></script>
<script src="{{ url_for('static', filename='js/srccfg-grammar.js') }}" defer nonce="{{ g.csp_nonce }}"></script>
<script src="{{ url_for('static', filename='js/editor.js') }}" defer nonce="{{ g.csp_nonce }}"></script>
```

- [ ] **Step 2: Commit**

```bash
git add l4d2web/l4d2web/templates/_editor_assets.html
git commit -m "feat(editor): add Jinja partial for editor asset includes

Five script/link tags consolidated so call-site templates only need a
single {% include '_editor_assets.html' %} to enable the widget."
```

---

## Task 6: Wire blueprint config to use the editor

**Files:**
- Modify: `l4d2web/l4d2web/templates/blueprint_detail.html` (line 52, end of `content` block)
- Modify: `l4d2web/tests/test_blueprints.py`

- [ ] **Step 1: Write the failing form-contract test**

Add to `l4d2web/tests/test_blueprints.py` (after the existing form-update test around line 254):

```python
def test_blueprint_detail_renders_editor_assets(user_client) -> None:
    with session_scope() as session:
        user = session.scalar(select(User).where(User.username == "alice"))
        blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
        session.add(blueprint)
        session.flush()
        blueprint_id = blueprint.id

    response = user_client.get(f"/blueprints/{blueprint_id}")
    assert response.status_code == 200
    body = response.get_data(as_text=True)
    # Editor opts the textarea in via a data-attribute.
    assert 'data-editor-language="srccfg"' in body
    # All editor assets are referenced.
    assert "static/vendor/prism.js" in body
    assert "static/vendor/prism.css" in body
    assert "static/vendor/codejar.js" in body
    assert "static/js/srccfg-grammar.js" in body
    assert "static/js/editor.js" in body
    assert "static/css/editor.css" in body
    # Scripts are nonce'd (CSP regression guard).
    assert 'nonce="' in body


def test_blueprint_config_form_post_still_round_trips(user_client) -> None:
    # The editor is a visual layer; the form POST contract must be
    # unaffected. This guards against accidentally renaming `config` or
    # dropping it from form serialization.
    with session_scope() as session:
        user = session.scalar(select(User).where(User.username == "alice"))
        blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
        session.add(blueprint)
        session.flush()
        blueprint_id = blueprint.id

    update = user_client.post(
        f"/blueprints/{blueprint_id}",
        data={
            "name": "bp",
            "arguments": "",
            "config": "sv_cheats 1\nmp_gamemode coop",
            "overlay_ids": [],
        },
        headers={"X-CSRF-Token": "test-token"},
    )
    assert update.status_code == 302

    with session_scope() as session:
        bp = session.get(Blueprint, blueprint_id)
        assert json.loads(bp.config) == ["sv_cheats 1", "mp_gamemode coop"]
```

- [ ] **Step 2: Run the tests to verify they fail**

```bash
cd l4d2web && uv run pytest tests/test_blueprints.py::test_blueprint_detail_renders_editor_assets tests/test_blueprints.py::test_blueprint_config_form_post_still_round_trips -v
```

Expected: both fail. The first because `data-editor-language="srccfg"` isn't in the HTML yet. The second may pass already (form contract isn't broken) — if so, leave it as a regression guard for later tasks.

- [ ] **Step 3: Edit blueprint_detail.html**

At line 52, change:

```jinja
<textarea name="config" rows="8" spellcheck="false">{{ config_lines | join('\n') }}</textarea>
```

to:

```jinja
<textarea name="config" rows="8" spellcheck="false" data-editor-language="srccfg">{{ config_lines | join('\n') }}</textarea>
```

At the very end of `{% block content %}` (after the existing `<script src="...blueprint-overlay-picker.js" defer></script>` line near line 94), add the partial include:

```jinja
{% include '_editor_assets.html' %}
```

- [ ] **Step 4: Run the tests to verify they pass**

```bash
cd l4d2web && uv run pytest tests/test_blueprints.py::test_blueprint_detail_renders_editor_assets tests/test_blueprints.py::test_blueprint_config_form_post_still_round_trips -v
```

Expected: both PASS.

- [ ] **Step 5: Manual smoke (Chrome MCP or local browser)**

Start the dev server, log in, navigate to `/blueprints/<some_id>`. Expected:
- The config textarea is visually replaced by the editor (looks similar to a textarea — no shifted layout).
- Existing content (`exec foo.cfg`, cvar lines) is preserved.
- Typing renders highlighted tokens (comments in muted color, keywords like `exec` in keyword color, numbers in number color).
- Submitting the form persists the edited content (refresh confirms).

- [ ] **Step 6: Commit**

```bash
git add l4d2web/l4d2web/templates/blueprint_detail.html l4d2web/tests/test_blueprints.py
git commit -m "feat(blueprint): mount srccfg editor on the config textarea

The textarea is preserved as the form field; the editor renders a
contenteditable sibling and mirrors content back on every input. Form
POST contract is untouched (covered by new round-trip test)."
```

---

## Task 7: Wire bash editor on overlay script form

**Files:**
- Modify: `l4d2web/l4d2web/templates/overlay_detail.html` (line 25, end of `content` block)
- Modify: `l4d2web/tests/test_script_overlay_routes.py`

- [ ] **Step 1: Write the failing form-contract test**

Append to `l4d2web/tests/test_script_overlay_routes.py`. The file already defines an `app` fixture, an `alice_id` fixture, a `_client_for(app, user_id)` helper, and a `_create_script_overlay(app, user_id, *, name)` helper. Reuse them:

```python
def test_script_overlay_detail_renders_bash_editor(app, alice_id) -> None:
    overlay_id = _create_script_overlay(app, alice_id, name="bash-editor-test")
    client = _client_for(app, alice_id)

    response = client.get(f"/overlays/{overlay_id}")
    assert response.status_code == 200
    body = response.get_data(as_text=True)
    assert 'data-editor-language="bash"' in body
    assert "static/js/editor.js" in body
    assert 'nonce="' in body
```

- [ ] **Step 2: Run the test to verify it fails**

```bash
cd l4d2web && uv run pytest tests/test_script_overlay_routes.py::test_script_overlay_detail_renders_bash_editor -v
```

Expected: FAIL — `data-editor-language="bash"` is not in the rendered HTML.

- [ ] **Step 3: Edit overlay_detail.html (line 25)**

Change:

```jinja
<textarea name="script" rows="20" spellcheck="false">{{ overlay.script or "" }}</textarea>
```

to:

```jinja
<textarea name="script" rows="20" spellcheck="false" data-editor-language="bash">{{ overlay.script or "" }}</textarea>
```

At the very end of `{% block content %}` (after the existing `<script src="...files-overlay.js" defer></script>` line near line 274, but outside the `{% if files_can_edit %}` block so it loads for script overlays too), add the partial include:

```jinja
{% include '_editor_assets.html' %}
```

- [ ] **Step 4: Run the test to verify it passes**

```bash
cd l4d2web && uv run pytest tests/test_script_overlay_routes.py::test_script_overlay_detail_renders_bash_editor -v
```

Expected: PASS.

- [ ] **Step 5: Manual smoke**

Navigate to a script-type overlay. Confirm bash highlighting: keywords like `for`, `if`, `done` should be highlighted; `$VAR` references colored; `#` comments muted.

- [ ] **Step 6: Commit**

```bash
git add l4d2web/l4d2web/templates/overlay_detail.html l4d2web/tests/test_script_overlay_routes.py
git commit -m "feat(overlay): mount bash editor on script overlay form

data-editor-language=bash opts the textarea in; the editor uses
Prism's stock bash grammar (no project-owned bash code)."
```

---

## Task 8: Seed srccfg vocabulary

**Files:**
- Create: `l4d2web/l4d2web/static/data/srccfg-vocab.json`

A minimal but useful seed list. The full L4D2 cvar list has ~3000 entries; this seed covers the highest-traffic ones a user is likely to type. Augment later as needed.

- [ ] **Step 1: Write the seed vocab**

Create `l4d2web/l4d2web/static/data/srccfg-vocab.json`:

```json
{
  "_comment": "Curated L4D2 cvars + commands for editor autocomplete. Regenerate by running `cvarlist` and `cmdlist` against a freshly-started L4D2 dedicated server with the project's common SourceMod plugins loaded, then hand-trimming engine internals nobody touches. Descriptions come from the trailing help text where present.",
  "cvars": [
    {"name": "sv_cheats", "desc": "Allow cheat cvars (0/1) — disables VAC"},
    {"name": "sv_pure", "desc": "Pure-server enforcement (0=off, 1=loose, 2=strict)"},
    {"name": "sv_consistency", "desc": "Force consistency on every client file (0/1)"},
    {"name": "sv_alltalk", "desc": "Cross-team voice chat (0/1)"},
    {"name": "sv_lan", "desc": "LAN-only server (0=internet, 1=LAN)"},
    {"name": "sv_voiceenable", "desc": "Enable voice chat (0/1)"},
    {"name": "sv_password", "desc": "Server join password (empty for open)"},
    {"name": "sv_logflush", "desc": "Flush log file after every line (0/1)"},
    {"name": "sv_minrate", "desc": "Minimum client bandwidth (bytes/sec)"},
    {"name": "sv_maxrate", "desc": "Maximum client bandwidth (bytes/sec)"},
    {"name": "sv_mincmdrate", "desc": "Minimum client command rate"},
    {"name": "sv_maxcmdrate", "desc": "Maximum client command rate"},
    {"name": "sv_minupdaterate", "desc": "Minimum server update rate"},
    {"name": "sv_maxupdaterate", "desc": "Maximum server update rate"},
    {"name": "sv_region", "desc": "Server browser region code"},
    {"name": "sv_steamgroup", "desc": "Steam group ID for restricted servers"},
    {"name": "sv_tags", "desc": "Comma-separated tags for the server browser"},
    {"name": "hostname", "desc": "Server name shown in the browser"},
    {"name": "rcon_password", "desc": "Remote-console admin password"},
    {"name": "mp_gamemode", "desc": "Game mode (coop, versus, survival, scavenge, realism)"},
    {"name": "mp_roundtime", "desc": "Round time limit (minutes)"},
    {"name": "z_difficulty", "desc": "AI director difficulty (Easy/Normal/Hard/Impossible)"},
    {"name": "director_no_specials", "desc": "Disable special-infected spawning (0/1)"},
    {"name": "director_no_bosses", "desc": "Disable tank/witch spawning (0/1)"},
    {"name": "director_panic_forever", "desc": "Endless horde panic event (0/1)"},
    {"name": "nb_update_frequency", "desc": "Infected bot AI tick frequency"},
    {"name": "fps_max", "desc": "Frame rate cap (0=uncapped)"},
    {"name": "tickrate", "desc": "Server tickrate (engine-dependent ceiling)"},
    {"name": "net_splitpacket_maxrate", "desc": "Maximum split-packet bandwidth"},
    {"name": "decalfrequency", "desc": "Anti-spam delay between sprays (seconds)"}
  ],
  "commands": [
    {"name": "exec", "desc": "Execute a .cfg file"},
    {"name": "alias", "desc": "Define a console-command alias"},
    {"name": "bind", "desc": "Bind a key to a command"},
    {"name": "unbind", "desc": "Remove a key binding"},
    {"name": "toggle", "desc": "Flip a 0/1 cvar"},
    {"name": "sm_cvar", "desc": "SourceMod: set a cvar bypassing sv_cheats restrictions"},
    {"name": "echo", "desc": "Print to console"},
    {"name": "say", "desc": "Send a chat message as the server"}
  ]
}
```

- [ ] **Step 2: Validate JSON parses**

```bash
python3 -m json.tool l4d2web/l4d2web/static/data/srccfg-vocab.json > /dev/null && echo OK
```

Expected: `OK`.

- [ ] **Step 3: Commit**

```bash
git add l4d2web/l4d2web/static/data/srccfg-vocab.json
git commit -m "data(editor): seed L4D2 cvar/command vocabulary

Hand-curated set of high-traffic cvars and commands sourced from the
existing l4d2-server-cvar-reference.md and common SourceMod usage.
Regeneration procedure documented in the file header."
```

---

## Task 9: Autocomplete popup in editor.js

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

Add: vocab lazy-loader, caret-position popup, keyboard navigation, accept/dismiss.

- [ ] **Step 1: Add the vocab loader**

Insert near the top of `editor.js`, after the `LANG_BY_EXT` constant:

```js
const VOCAB_URLS = {
  srccfg: "/static/data/srccfg-vocab.json",
};

const vocabCache = {};

async function loadVocab(lang) {
  if (vocabCache[lang]) return vocabCache[lang];
  const url = VOCAB_URLS[lang];
  if (!url) return [];
  try {
    const r = await fetch(url);
    if (!r.ok) return [];
    const data = await r.json();
    const merged = []
      .concat(data.cvars || [])
      .concat(data.commands || []);
    vocabCache[lang] = merged;
    return merged;
  } catch (err) {
    console.warn("[editor] vocab load failed for " + lang, err);
    vocabCache[lang] = [];
    return [];
  }
}
```

- [ ] **Step 2: Add the popup helpers**

Insert after `loadVocab`:

```js
const WORD_BEFORE_CARET = /[A-Za-z0-9_]{2,}$/;

function getCaretContext(codeEl) {
  const sel = window.getSelection();
  if (!sel || !sel.rangeCount) return null;
  const range = sel.getRangeAt(0).cloneRange();
  if (!codeEl.contains(range.endContainer)) return null;
  // Build the text from start of the editor up to the caret.
  const pre = range.cloneRange();
  pre.selectNodeContents(codeEl);
  pre.setEnd(range.endContainer, range.endOffset);
  const textBefore = pre.toString();
  const m = WORD_BEFORE_CARET.exec(textBefore);
  if (!m) return null;
  return {
    fragment: m[0],
    rect: range.getBoundingClientRect(),
  };
}

function filterVocab(vocab, fragment) {
  const lower = fragment.toLowerCase();
  const prefix = [];
  const substr = [];
  for (const entry of vocab) {
    const name = entry.name.toLowerCase();
    if (name.startsWith(lower)) prefix.push(entry);
    else if (name.includes(lower)) substr.push(entry);
    if (prefix.length + substr.length >= 50) break;
  }
  return prefix.concat(substr).slice(0, 50);
}

function renderPopup(popup, items, activeIndex) {
  popup.innerHTML = "";
  const visible = items.slice(0, 8);
  visible.forEach((entry, i) => {
    const li = document.createElement("li");
    li.className = "editor-popup-item" + (i === activeIndex ? " is-active" : "");
    li.dataset.index = String(i);
    const name = document.createElement("span");
    name.className = "name";
    name.textContent = entry.name;
    li.appendChild(name);
    if (entry.desc) {
      const desc = document.createElement("span");
      desc.className = "desc";
      desc.textContent = "— " + entry.desc;
      li.appendChild(desc);
    }
    popup.appendChild(li);
  });
}

function positionPopup(popup, rect) {
  popup.style.left = (window.scrollX + rect.left) + "px";
  popup.style.top = (window.scrollY + rect.bottom + 2) + "px";
}
```

- [ ] **Step 3: Wire the popup into the editor instance**

Inside `mount(textarea)`, after the existing `jar.onUpdate(...)` block, add:

```js
let popup = null;
let popupItems = [];
let popupActive = 0;

function ensurePopup() {
  if (popup) return popup;
  popup = document.createElement("ul");
  popup.className = "editor-popup";
  popup.style.display = "none";
  document.body.appendChild(popup);
  popup.addEventListener("mousedown", function (e) {
    e.preventDefault(); // keep caret in editor
    const li = e.target.closest(".editor-popup-item");
    if (!li) return;
    acceptCompletion(popupItems[parseInt(li.dataset.index, 10)]);
  });
  return popup;
}

function hidePopup() {
  if (popup) popup.style.display = "none";
  popupItems = [];
}

function acceptCompletion(entry) {
  if (!entry) return;
  const ctx = getCaretContext(code);
  if (!ctx) {
    hidePopup();
    return;
  }
  // Replace the trailing word fragment with the chosen identifier.
  const sel = window.getSelection();
  const range = sel.getRangeAt(0);
  range.setStart(range.endContainer, range.endOffset - ctx.fragment.length);
  range.deleteContents();
  range.insertNode(document.createTextNode(entry.name));
  range.collapse(false);
  sel.removeAllRanges();
  sel.addRange(range);
  // Force CodeJar to re-highlight + emit onUpdate.
  // Use instance.jar (not bare `jar` closure) for consistency with
  // setLanguage's tear-down-and-remount — the jar reference can be
  // swapped at runtime.
  instance.jar.updateCode(instance.jar.toString());
  hidePopup();
}

async function refreshPopup() {
  if (instance.language === "plain") {
    hidePopup();
    return;
  }
  const ctx = getCaretContext(code);
  if (!ctx) {
    hidePopup();
    return;
  }
  const vocab = await loadVocab(instance.language);
  if (!vocab.length) {
    hidePopup();
    return;
  }
  const filtered = filterVocab(vocab, ctx.fragment);
  if (!filtered.length) {
    hidePopup();
    return;
  }
  popupItems = filtered;
  popupActive = 0;
  ensurePopup();
  renderPopup(popup, popupItems, popupActive);
  positionPopup(popup, ctx.rect);
  popup.style.display = "";
}

code.addEventListener("input", refreshPopup);
code.addEventListener("blur", function () {
  // Defer hide so a popup click can still register.
  setTimeout(hidePopup, 100);
});

// IMPORTANT: capture-phase listener + stopPropagation.
//
// CodeJar registers its own keydown handler on this same element during
// construction (line ~159 above). Its Tab handler unconditionally
// preventDefaults and inserts 2 spaces; its Enter handler does the same
// (+ stopPropagation when there's leading indent). Bubble-phase order is
// registration order — CodeJar would consume Tab/Enter before our popup
// handler ever sees them.
//
// Capture-phase listener fires first. Calling stopPropagation here
// prevents CodeJar's bubble handler from running for the keys we own
// while the popup is visible. Normal typing (popup hidden) early-returns
// without stopPropagation so CodeJar's other behavior is preserved.
code.addEventListener("keydown", function (e) {
  if (!popup || popup.style.display === "none" || !popupItems.length) return;
  if (e.key === "ArrowDown") {
    popupActive = (popupActive + 1) % Math.min(popupItems.length, 8);
    renderPopup(popup, popupItems, popupActive);
    e.preventDefault();
    e.stopPropagation();
  } else if (e.key === "ArrowUp") {
    popupActive =
      (popupActive - 1 + Math.min(popupItems.length, 8)) %
      Math.min(popupItems.length, 8);
    renderPopup(popup, popupItems, popupActive);
    e.preventDefault();
    e.stopPropagation();
  } else if (e.key === "Tab" || e.key === "Enter") {
    acceptCompletion(popupItems[popupActive]);
    e.preventDefault();
    e.stopPropagation();
  } else if (e.key === "Escape") {
    hidePopup();
    e.preventDefault();
    e.stopPropagation();
  }
}, true);
```

- [ ] **Step 4: Manual smoke**

Open a blueprint detail page. Type `sv_che`. Expected:
- Popup appears below the caret listing `sv_cheats` (highlighted first).
- ↓ moves the highlight, ↑ moves it back.
- Tab inserts `sv_cheats` replacing `sv_che`.
- Esc dismisses without inserting.
- Clicking on a popup item inserts that item.

Try edge cases: typing `xyzzy` (no match) hides the popup; pressing Esc, then continuing to type re-opens it; switching languages via the files-editor dropdown disables srccfg autocomplete when set to bash (because there's no bash vocab URL).

- [ ] **Step 5: Commit**

```bash
git add l4d2web/l4d2web/static/js/editor.js
git commit -m "feat(editor): add identifier autocomplete popup

Vocab loaded lazily from /static/data/<lang>-vocab.json on first
mount, cached in memory. Popup appears when the word fragment before
the caret has ≥2 word characters and matches the vocabulary. Prefix
matches rank ahead of substring matches; popup shows up to 8 with
scroll. ↑/↓ navigate, Tab/Enter accept, Esc dismisses."
```

---

## Task 10: Files-editor modal integration

**Files:**
- Modify: `l4d2web/l4d2web/templates/overlay_detail.html` (~line 178)
- Modify: `l4d2web/l4d2web/static/js/files-overlay.js` (~lines 345, 385)

The files-editor modal opens for a different file each time, so the widget needs `setValue(content)` and `setLanguage(name)` calls when the modal opens. The dropdown lets the user override the auto-detected language.

- [ ] **Step 1: Edit overlay_detail.html (around line 178)**

Locate the existing block:

```jinja
<label class="files-editor-field">
  <span class="files-field-label">Content</span>
  <textarea class="files-editor-content" rows="14" spellcheck="false"></textarea>
</label>
```

Replace with:

```jinja
<label class="files-editor-field">
  <span class="files-field-label">Content</span>
  <textarea class="files-editor-content" rows="14" spellcheck="false" data-editor-language="auto"></textarea>
</label>
<label class="files-editor-field files-editor-language-field">
  <span class="files-field-label">Language</span>
  <select class="files-editor-language editor-language-select">
    <option value="auto" selected>Auto (from filename)</option>
    <option value="srccfg">Source config (.cfg)</option>
    <option value="bash">Bash (.sh)</option>
    <option value="plain">Plain text</option>
  </select>
</label>
```

The `_editor_assets.html` include added in Task 7 already covers this template, so no additional script tags are needed.

- [ ] **Step 2: Bridge files-overlay.js to call setValue/setLanguage**

In `l4d2web/l4d2web/static/js/files-overlay.js`, locate the two places that assign to `editorEls.contentBox.value`:

- Around line 345 (`openEditorTextNew`): `editorEls.contentBox.value = "";`
- Around line 385 (`openEditorForFile`): `editorEls.contentBox.value = r.body.content;`

Replace each with a helper that goes through the editor when one is mounted:

Add this helper near the top of `files-overlay.js` (after the existing `editorDialog = document.getElementById(...)` line):

```js
function setEditorContent(text) {
  const editor = editorEls.contentBox._codeEditor;
  if (editor) {
    editor.setValue(text);
    editor.setLanguage("auto"); // re-derive from filename
  } else {
    editorEls.contentBox.value = text;
  }
}
```

Then update the two call sites:

```js
// In openEditorTextNew (was: editorEls.contentBox.value = "";)
setEditorContent("");

// In openEditorForFile (was: editorEls.contentBox.value = r.body.content;)
setEditorContent(r.body.content);
```

The transient "Loading…" assignment around line 378 (`editorEls.contentBox.value = "Loading…";`) can also be swapped to `setEditorContent("Loading…");` for consistency.

- [ ] **Step 3: Wire the language dropdown**

Add right after the `setEditorContent` helper:

```js
const languageSelect = document.querySelector(".files-editor-language");
if (languageSelect) {
  languageSelect.addEventListener("change", function () {
    const editor = editorEls.contentBox._codeEditor;
    if (editor) editor.setLanguage(languageSelect.value);
  });
}
```

Also, when the filename input changes and the dropdown is still on `auto`, re-derive. Inside the existing filename-input handler (search for `editorEls.filename.addEventListener("input"`), append a call to `setEditorContent(jar.getValue())` — wait, that's not right; we just want to re-trigger the auto-detection. Use:

```js
// Append inside the existing filename input handler:
const _editor = editorEls.contentBox._codeEditor;
if (_editor && languageSelect && languageSelect.value === "auto") {
  _editor.setLanguage("auto");
}
```

- [ ] **Step 4: Manual smoke**

Open a files-type overlay. Click `+ new file` on the root row, name it `test.cfg`, paste some `sv_cheats 1`-style content. Expected:
- Editor mounts inside the modal.
- Language dropdown shows "Auto (from filename)".
- Content highlighted as srccfg (cvar tokens colored).
- Type `sv_che` → autocomplete popup with `sv_cheats`.
- Switch dropdown to "Bash (.sh)" → re-highlights with bash grammar (no autocomplete because no bash vocab).
- Switch dropdown to "Auto" → re-highlights as srccfg.
- Click Save → file is saved (verify by reopening it).
- Open the file again → editor loads content with srccfg highlighting (auto-detected from the .cfg extension).

- [ ] **Step 5: Commit**

```bash
git add l4d2web/l4d2web/templates/overlay_detail.html l4d2web/l4d2web/static/js/files-overlay.js
git commit -m "feat(files-editor): mount auto-language editor + dropdown override

The modal textarea opts in with data-editor-language=auto; the editor
derives the language from the filename extension on each modal open.
A dropdown lets the user override (srccfg / bash / plain). The
existing fetch-based /files/save path is unchanged — files-overlay.js
keeps reading textarea.value, which the editor mirrors."
```

---

## Task 11: Playwright scaffolding

**Files:**
- Modify: `l4d2web/pyproject.toml`
- Create: `l4d2web/tests/e2e/__init__.py`
- Create: `l4d2web/tests/e2e/conftest.py`
- Modify: `AGENTS.md` (repo root)

- [ ] **Step 1: Inspect the existing dev-deps shape**

```bash
grep -A 20 "\[project.optional-dependencies\]\|\[dependency-groups\]\|\[tool.uv\]" l4d2web/pyproject.toml
```

Note which group dev deps live under (`dev`, `test`, etc.).

- [ ] **Step 2: Add playwright to dev deps**

Edit `l4d2web/pyproject.toml`. In the dev-deps group identified in Step 1, append:

```toml
"playwright>=1.49.0",
"pytest-playwright>=0.6.0",
```

- [ ] **Step 3: Install the dep + chromium binary**

```bash
cd l4d2web && uv sync
cd l4d2web && uv run playwright install chromium
```

Expected: `playwright install chromium` downloads the browser binary (a few hundred MB) and prints a "chromium installed" line.

- [ ] **Step 4: Configure pytest to register the e2e marker**

In `l4d2web/pyproject.toml`, find the `[tool.pytest.ini_options]` block (or create one). Ensure it contains:

```toml
[tool.pytest.ini_options]
markers = [
  "e2e: end-to-end browser tests (slow, require chromium)",
]
```

If markers already exist, append the `e2e` entry to the list.

- [ ] **Step 5: Create the e2e test directory + conftest**

```bash
touch l4d2web/tests/e2e/__init__.py
```

Create `l4d2web/tests/e2e/conftest.py`:

```python
"""Pytest fixtures for end-to-end browser tests.

Boots the Flask app in a background thread on an ephemeral port and
yields the base URL. The app uses a temp SQLite DB so e2e runs don't
contaminate the dev database.
"""

import socket
import threading
from datetime import UTC, datetime

import pytest
from werkzeug.serving import make_server

from l4d2web.app import create_app
from l4d2web.auth import hash_password
from l4d2web.db import init_db, session_scope
from l4d2web.models import Blueprint, User


def _free_port() -> int:
    s = socket.socket()
    s.bind(("127.0.0.1", 0))
    port = s.getsockname()[1]
    s.close()
    return port


@pytest.fixture(scope="function")
def live_server(tmp_path, monkeypatch):
    db_path = tmp_path / "e2e.db"
    db_url = f"sqlite:///{db_path}"
    monkeypatch.setenv("DATABASE_URL", db_url)
    app = create_app({"TESTING": False, "DATABASE_URL": db_url, "SECRET_KEY": "e2e"})
    init_db()

    with session_scope() as session:
        user = User(
            username="alice",
            password_digest=hash_password("secret"),
            admin=False,
        )
        session.add(user)
        session.flush()
        bp = Blueprint(
            user_id=user.id, name="bp", arguments="[]", config="[]"
        )
        session.add(bp)
        session.flush()
        blueprint_id = bp.id
        user_id = user.id

    port = _free_port()
    server = make_server("127.0.0.1", port, app)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    try:
        yield {
            "base_url": f"http://127.0.0.1:{port}",
            "user_id": user_id,
            "blueprint_id": blueprint_id,
        }
    finally:
        server.shutdown()
        thread.join(timeout=2)
```

- [ ] **Step 6: Document playwright install in AGENTS.md**

Add a short subsection to the repo-root `AGENTS.md`, under whatever the existing "Local setup" section is named (grep for it; if there's no obvious section, append at end):

```markdown
### End-to-end tests

The Playwright-based browser tests under `l4d2web/tests/e2e/` need a
chromium binary, fetched on first setup:

```bash
cd l4d2web && uv run playwright install chromium
```

Run with `cd l4d2web && uv run pytest -m e2e`. Excluded from the
default fast suite via the `e2e` marker.
```

- [ ] **Step 7: Smoke-test the fixture (no real test yet)**

Create `l4d2web/tests/e2e/test_smoke.py`:

```python
import pytest


@pytest.mark.e2e
def test_live_server_boots(live_server) -> None:
    import urllib.request

    resp = urllib.request.urlopen(live_server["base_url"] + "/login")
    assert resp.status == 200
```

Run:

```bash
cd l4d2web && uv run pytest -m e2e -v
```

Expected: PASS.

- [ ] **Step 8: Commit**

```bash
git add l4d2web/pyproject.toml l4d2web/tests/e2e/ AGENTS.md
git commit -m "test(e2e): scaffold Playwright + live-server fixture

Adds playwright + pytest-playwright dev deps, an e2e marker, and a
fixture that boots the Flask app on an ephemeral port with a temp
SQLite DB. Smoke test confirms the live server is reachable."
```

---

## Task 12: Playwright editor test (red → green)

**Files:**
- Create: `l4d2web/tests/e2e/test_editor.py`
- Modify (delete): `l4d2web/tests/e2e/test_smoke.py` (optional — keep if you like the heartbeat coverage)

- [ ] **Step 1: Write the failing editor test**

Create `l4d2web/tests/e2e/test_editor.py`:

```python
"""End-to-end test for the textarea code editor.

Logs in as the seed user, navigates to the blueprint detail page, types
`sv_che` into the editor, asserts the autocomplete popup appears with
`sv_cheats` highlighted, accepts via Tab, and asserts the underlying
textarea now contains `sv_cheats`.
"""

import pytest
from playwright.sync_api import expect, sync_playwright


@pytest.mark.e2e
def test_editor_autocomplete_inserts_cvar(live_server) -> None:
    base = live_server["base_url"]
    blueprint_id = live_server["blueprint_id"]

    with sync_playwright() as p:
        browser = p.chromium.launch()
        ctx = browser.new_context()
        page = ctx.new_page()

        # Log in.
        page.goto(f"{base}/login")
        page.fill('input[name="username"]', "alice")
        page.fill('input[name="password"]', "secret")
        page.click('button[type="submit"]')
        expect(page).to_have_url(f"{base}/dashboard", timeout=5000)

        # Navigate to the seeded blueprint.
        page.goto(f"{base}/blueprints/{blueprint_id}")

        # Editor mounts on DOMContentLoaded; the contenteditable replaces
        # the textarea visually. Wait for it.
        editor = page.locator(".editor-code").first
        expect(editor).to_be_visible(timeout=5000)

        # Focus the editor and type a cvar prefix.
        editor.click()
        page.keyboard.type("sv_che")

        # The popup should appear and contain sv_cheats.
        popup = page.locator(".editor-popup")
        expect(popup).to_be_visible(timeout=2000)
        expect(popup).to_contain_text("sv_cheats")

        # Accept via Tab.
        page.keyboard.press("Tab")

        # The hidden textarea (form field) must now contain the cvar.
        textarea_value = page.evaluate(
            "() => document.querySelector('textarea[name=config]').value"
        )
        assert "sv_cheats" in textarea_value

        browser.close()
```

- [ ] **Step 2: Run the test to verify it fails (or passes)**

```bash
cd l4d2web && uv run pytest l4d2web/tests/e2e/test_editor.py -v
```

If all prior tasks landed correctly, this should PASS. If it fails, debug:
- Run with `--headed` (add `p.chromium.launch(headless=False, slow_mo=300)`) to watch the browser.
- Check the console for JS errors via Playwright's `page.on('console', print)`.

- [ ] **Step 3: Run the full default suite to confirm no regressions**

```bash
cd l4d2web && uv run pytest -v
```

Expected: all existing tests pass (the new form-contract tests from Tasks 6/7 are included).

```bash
cd l4d2web && uv run pytest -m e2e -v
```

Expected: e2e suite passes.

- [ ] **Step 4: Final commit**

```bash
git add l4d2web/tests/e2e/test_editor.py
git commit -m "test(e2e): editor autocomplete end-to-end

Logs in, navigates to a blueprint, types sv_che, asserts the popup
appears with sv_cheats, accepts via Tab, and asserts the form
textarea's value contains the inserted cvar."
```

---

## Self-Review (run after writing the plan; do not commit a separate review file)

Spec coverage:

- [x] Blueprint config textarea — Task 6
- [x] Overlay script (bash) textarea — Task 7
- [x] Files-editor modal textarea — Task 10
- [x] Auto language detection from filename — Task 10
- [x] Language dropdown in files-editor — Task 10
- [x] `srccfg-grammar.js` — Task 2
- [x] CodeJar + Prism vendored — Task 1
- [x] `_editor_assets.html` partial — Task 5
- [x] `editor.css` dedicated stylesheet — Task 3
- [x] `srccfg-vocab.json` curated vocab — Task 8
- [x] Lazy vocab loading + caching — Task 9 (`loadVocab` + `vocabCache`)
- [x] Autocomplete trigger + filter + keyboard + mouse — Task 9
- [x] Form-contract tests (GET + POST round-trip) — Task 6 (blueprint), Task 7 (overlay)
- [x] Playwright scaffold + e2e test — Tasks 11 + 12
- [x] No backend code changes — confirmed: `blueprint_routes.py`, `app.py`, `/files/save` route untouched

Closed items in spec (line numbers, multi-cursor, etc.) — out of scope by design.

Open issue noted during planning:

- **CodeJar caret preservation across `setLanguage`** — Task 4 Step 2 explicitly accepts caret-loss on language switch (rare action, no UX regression). If a future task needs caret-preserved language switching, expect to introduce a thin wrapper that re-mounts CodeJar while recording the previous selection offset.
- **Bash autocomplete vocab** — none in v1, by spec. The popup simply won't appear when language is `bash`. `VOCAB_URLS` is structured to make adding a `bash` entry trivial later.

---

## Reference snippets and where to look them up

- **Form contract POST shape** — `l4d2web/l4d2web/routes/blueprint_routes.py:117-142` reads `request.form.get("config")` and splits on newlines. Do not change this in any task; the editor preserves the textarea's `name="config"` and pipes value back on input.
- **CSP nonce accessor** — `l4d2web/l4d2web/app.py:84-86` exposes `g.csp_nonce` via `before_request`. All editor `<script>` tags use it; no inline scripts anywhere.
- **Existing progressive-enhancement pattern** — `l4d2web/l4d2web/static/js/blueprint-overlay-picker.js:20-26,63` manipulates hidden inputs directly. The editor follows the same shape: no submit interception, just DOM mirroring.
- **Files-editor save path** — `l4d2web/l4d2web/static/js/files-overlay.js:450-557`. JSON-fetch POST of `{path, content}` where `content` is `editorEls.contentBox.value`. The editor's `onUpdate` callback keeps `.value` in sync; the save call site needs no changes (only the `value =` *assignments* at lines 345 + 385 do).
