left4me/docs/superpowers/plans/2026-05-16-textarea-code-editor.md
mwiegand c6f10e632d
test(blueprint): also assert prism.css is referenced in editor assets
The plan template (and verbatim implementation) listed five of the six
editor asset URLs in the structural test — vendor/prism.css was
omitted. If a future change drops the Prism stylesheet from the
partial, syntax tokens lose their color rules silently and the test
still passes. Add the missing assertion and update the plan to match.

Addresses Minor #1 from the Task 6 code review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:35:44 +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.
  jar.updateCode(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).