left4me/docs/superpowers/plans/2026-05-16-textarea-code-editor.md
mwiegand 4bace3ab5a
plan(textarea-editor): fix stale jar reference in autocomplete
The Task 9 plan template used the captured \`jar\` closure variable in
acceptCompletion, which becomes stale after setLanguage's
tear-down-and-remount. Same class of bug Task 4's review caught and
fixed. Update the plan to match the correct implementation.
2026-05-16 20:42:16 +02:00

56 KiB
Raw Blame History

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.

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:

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 1525 KB; prism.css around 13 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:

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:

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:

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

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

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
ls -la l4d2web/l4d2web/static/vendor/

Expected output includes codejar.js, prism.css, prism.js, README.md. File sizes: prism.js around 2030 KB; prism.css around 13 KB; codejar.js around 35 KB.

  • Step 5: Commit
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:

// 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):

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
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
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:

/* 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

/* 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
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.

  • 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.
// 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:

{# 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
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):

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
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:

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

to:

<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:

{% include '_editor_assets.html' %}
  • Step 4: Run the tests to verify they pass
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

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:

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
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:

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

to:

<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:

{% include '_editor_assets.html' %}
  • Step 4: Run the test to verify it passes
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
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:

{
  "_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
python3 -m json.tool l4d2web/l4d2web/static/data/srccfg-vocab.json > /dev/null && echo OK

Expected: OK.

  • Step 3: Commit
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:

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:

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:

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);
});

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();
  } else if (e.key === "ArrowUp") {
    popupActive =
      (popupActive - 1 + Math.min(popupItems.length, 8)) %
      Math.min(popupItems.length, 8);
    renderPopup(popup, popupItems, popupActive);
    e.preventDefault();
  } else if (e.key === "Tab" || e.key === "Enter") {
    acceptCompletion(popupItems[popupActive]);
    e.preventDefault();
  } else if (e.key === "Escape") {
    hidePopup();
    e.preventDefault();
  }
});
  • 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
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:

<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:

<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):

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:

// 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:

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:

// 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

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

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:

"playwright>=1.49.0",
"pytest-playwright>=0.6.0",
  • Step 3: Install the dep + chromium binary
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:

[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
touch l4d2web/tests/e2e/__init__.py

Create l4d2web/tests/e2e/conftest.py:

"""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):

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

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

Expected: PASS.

  • Step 8: Commit
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:

"""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)
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

cd l4d2web && uv run pytest -v

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

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

Expected: e2e suite passes.

  • Step 4: Final commit
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:

  • Blueprint config textarea — Task 6
  • Overlay script (bash) textarea — Task 7
  • Files-editor modal textarea — Task 10
  • Auto language detection from filename — Task 10
  • Language dropdown in files-editor — Task 10
  • srccfg-grammar.js — Task 2
  • CodeJar + Prism vendored — Task 1
  • _editor_assets.html partial — Task 5
  • editor.css dedicated stylesheet — Task 3
  • srccfg-vocab.json curated vocab — Task 8
  • Lazy vocab loading + caching — Task 9 (loadVocab + vocabCache)
  • Autocomplete trigger + filter + keyboard + mouse — Task 9
  • Form-contract tests (GET + POST round-trip) — Task 6 (blueprint), Task 7 (overlay)
  • Playwright scaffold + e2e test — Tasks 11 + 12
  • 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 shapel4d2web/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 accessorl4d2web/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 patternl4d2web/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 pathl4d2web/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).