15 tasks covering: editor-src scaffold, vocab generator, srccfg StreamLanguage mode, light/dark themes, autocomplete source, editor-entry façade, esbuild build script + first bundle, tokens.css + editor.css, editor.js glue (mount + submit-capture + __filesEditor alias), _editor_assets.html partial, form-contract pytest pre-wiring gate, template wiring with GET-asserts-markup TDD, files-overlay.js bridge swap, Playwright e2e (autocomplete-accept + copy regression), docs + final smoke. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
46 KiB
Textarea Code Editor v2 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Wire CodeMirror 6 (bundled, self-hosted) into three textareas in l4d2web with syntax highlighting (srccfg + bash) and identifier-as-you-type autocomplete fed by ~2196 cvars/commands generated from ./cvar_list, without changing the form-POST or fetch('/files/save') contracts.
Architecture: Pre-built esbuild IIFE bundle (editor.bundle.js) exposing window.__editor.mount(textarea, opts) → controller. Submit-time copy form bridge: capture-phase submit handler copies controller.getValue() into the hidden textarea once per submit; JSON-save path calls getValue() directly. cm6 owns the live doc. Per-page CSS asset partial keeps the editor off pages that don't mount one.
Tech Stack: CodeMirror 6 (@codemirror/{state,view,commands,language,autocomplete} + @codemirror/legacy-modes), esbuild for bundling, Python pytest + Playwright for tests, vanilla JS glue, Jinja templates, Flask backend (untouched).
Spec: docs/superpowers/specs/2026-05-17-textarea-editor-v2-design.md. Handoff context (read first): docs/superpowers/specs/2026-05-17-textarea-editor-handoff.md.
File map
What each file owns. Tasks below produce them.
| File | Owner / purpose |
|---|---|
l4d2web/scripts/editor-src/package.json |
npm dep manifest (pinned) |
l4d2web/scripts/editor-src/package-lock.json |
reproducible build lock |
l4d2web/scripts/editor-src/.gitignore |
excludes node_modules/ |
l4d2web/scripts/editor-src/editor-entry.js |
imports cm6 modules; exports window.__editor façade |
l4d2web/scripts/editor-src/srccfg-mode.js |
StreamLanguage.define(…) for Source-engine .cfg |
l4d2web/scripts/editor-src/themes.js |
light + dark EditorView.theme() exports |
l4d2web/scripts/editor-src/autocomplete.js |
CompletionSource over the vocab JSON |
l4d2web/scripts/build-editor.sh |
esbuild invocation; outputs to static/vendor/ |
l4d2web/scripts/build-vocab.py |
parse ./cvar_list → srccfg-vocab.json |
l4d2web/l4d2web/static/vendor/editor.bundle.js |
built bundle (committed artifact) |
l4d2web/l4d2web/static/vendor/editor.bundle.css |
built CSS (committed artifact) |
l4d2web/l4d2web/static/vendor/editor.bundle.sha256 |
integrity hashes |
l4d2web/l4d2web/static/vendor/README.md |
build cmd, dep versions, regen instructions |
l4d2web/l4d2web/static/js/editor.js |
un-bundled glue: mount loop, submit-capture, __filesEditor alias |
l4d2web/l4d2web/static/css/editor.css |
CSS-variable bridge: cm6 classes ← tokens.css |
l4d2web/l4d2web/static/css/tokens.css |
gains --syntax-* and --cm-* variables in light + dark blocks |
l4d2web/l4d2web/static/data/srccfg-vocab.json |
generated vocab (committed) |
l4d2web/l4d2web/templates/_editor_assets.html |
Jinja partial: nonce'd <link>/<script> tags |
l4d2web/l4d2web/templates/blueprint_detail.html |
+ data-editor-language="srccfg" on line-52 textarea; include partial |
l4d2web/l4d2web/templates/overlay_detail.html |
+ data-editor-language="bash" on line-25 textarea; + data-editor-language="auto" + <select> on line-178 textarea; include partial |
l4d2web/l4d2web/static/js/files-overlay.js |
5 reads + 3 writes routed via window.__filesEditor |
l4d2web/tests/test_blueprints.py |
extend: form-contract + GET-asserts-editor-markup tests |
l4d2web/tests/test_script_overlay_routes.py |
extend: same shape as test_blueprints.py |
l4d2web/tests/e2e/test_editor.py |
new Playwright: type→popup→accept→submit; copy regression gate |
cvar_list |
input, committed as data file with a header comment |
AGENTS.md |
doc node + npm + ./scripts/build-editor.sh |
Task 1: Scaffold editor-src/ with pinned cm6 deps
Files:
-
Create:
l4d2web/scripts/editor-src/package.json -
Create:
l4d2web/scripts/editor-src/.gitignore -
Create:
l4d2web/scripts/editor-src/editor-entry.js(stub, exports nothing yet) -
Step 1: Write
package.json
{
"name": "l4d2web-editor",
"version": "0.1.0",
"description": "CodeMirror 6 bundle for l4d2web textarea upgrade",
"private": true,
"type": "module",
"scripts": {
"build": "esbuild editor-entry.js --bundle --minify --format=iife --global-name=__editor_pkg --outfile=../../l4d2web/static/vendor/editor.bundle.js --metafile=meta.json"
},
"devDependencies": {
"esbuild": "^0.24.0"
},
"dependencies": {
"@codemirror/state": "^6.5.0",
"@codemirror/view": "^6.36.0",
"@codemirror/commands": "^6.8.0",
"@codemirror/language": "^6.10.0",
"@codemirror/autocomplete": "^6.18.0",
"@codemirror/legacy-modes": "^6.4.0"
}
}
- Step 2: Write
.gitignore
node_modules/
meta.json
- Step 3: Write a stub
editor-entry.js
// Façade entry point. Populated in Task 4.
window.__editor = { mount: () => { throw new Error('editor bundle not yet implemented'); } };
- Step 4: Install deps to produce the lockfile
Run from l4d2web/scripts/editor-src/: npm install
Expected: creates node_modules/ and package-lock.json.
- Step 5: Verify esbuild is invokable
Run: npx esbuild --version
Expected: prints 0.24.x (or whatever was resolved).
- Step 6: Commit
git add l4d2web/scripts/editor-src/package.json \
l4d2web/scripts/editor-src/package-lock.json \
l4d2web/scripts/editor-src/.gitignore \
l4d2web/scripts/editor-src/editor-entry.js
git commit -m "scaffold(editor-v2): pin cm6 deps + editor-src skeleton"
Task 2: Vocab generator (build-vocab.py)
Files:
- Create:
l4d2web/scripts/build-vocab.py - Create:
l4d2web/l4d2web/static/data/srccfg-vocab.json(generated)
The repo-root cvar_list is a Source-engine cvarlist dump:
name : value-or-"cmd" : flags : description (whitespace-padded). Two
header lines, then ~2196 rows. Descriptions may contain extra :,
so split with maxsplit=3.
- Step 1: Write
build-vocab.py
Path: l4d2web/scripts/build-vocab.py — chmod 755.
#!/usr/bin/env python3
"""Convert ./cvar_list (cvarlist dump) to static/data/srccfg-vocab.json.
Usage: ./l4d2web/scripts/build-vocab.py
Reads from the repo root (auto-detected via this script's path).
Writes idempotently. Run after regenerating cvar_list."""
from __future__ import annotations
import hashlib
import json
import pathlib
import re
import sys
HERE = pathlib.Path(__file__).resolve().parent
REPO_ROOT = HERE.parent.parent
SOURCE = REPO_ROOT / "cvar_list"
DEST = REPO_ROOT / "l4d2web" / "l4d2web" / "static" / "data" / "srccfg-vocab.json"
def parse(text: str) -> tuple[list[dict], list[dict]]:
cvars: list[dict] = []
commands: list[dict] = []
for raw in text.splitlines()[2:]: # skip "cvar list" + "--------…"
if not raw.strip():
continue
parts = [p.strip() for p in re.split(r"\s*:\s*", raw, maxsplit=3)]
if len(parts) < 3:
continue
name = parts[0]
value = parts[1]
desc = parts[3] if len(parts) >= 4 else ""
target = commands if value == "cmd" else cvars
target.append({"name": name, "desc": desc} if desc else {"name": name})
return cvars, commands
def main() -> int:
if not SOURCE.exists():
print(f"ERROR: {SOURCE} not found", file=sys.stderr)
return 1
text = SOURCE.read_text(encoding="utf-8", errors="replace")
cvars, commands = parse(text)
src_sha = hashlib.sha256(text.encode("utf-8")).hexdigest()
payload = {
"version": 1,
"generated_from": "cvar_list",
"source_sha256": src_sha,
"cvars": cvars,
"commands": commands,
}
DEST.parent.mkdir(parents=True, exist_ok=True)
DEST.write_text(json.dumps(payload, indent=2, sort_keys=False) + "\n", encoding="utf-8")
print(f"wrote {DEST}: {len(cvars)} cvars + {len(commands)} commands")
return 0
if __name__ == "__main__":
raise SystemExit(main())
- Step 2: Run it
From repo root: ./l4d2web/scripts/build-vocab.py
Expected stdout (numbers may differ by a few entries):
wrote …/l4d2web/l4d2web/static/data/srccfg-vocab.json: 1100 cvars + 1096 commands
- Step 3: Verify shape
python3 -c 'import json; d = json.load(open("l4d2web/l4d2web/static/data/srccfg-vocab.json")); print(d["version"], len(d["cvars"]), len(d["commands"])); print(d["cvars"][0]); print(d["commands"][0])'
Expected: 1 <N_cvars> <N_cmds> plus two sample entries with name (and optionally desc) keys.
- Step 4: Verify a known entry
python3 -c 'import json; d = json.load(open("l4d2web/l4d2web/static/data/srccfg-vocab.json")); print([c for c in d["cvars"] if c["name"] == "adrenaline_duration"])'
Expected: [{'name': 'adrenaline_duration'}] (no description on that line in the dump).
- Step 5: Commit
git add l4d2web/scripts/build-vocab.py \
l4d2web/l4d2web/static/data/srccfg-vocab.json \
cvar_list
git commit -m "feat(editor-v2): vocab generator + cvar_list-derived JSON"
Note: cvar_list is included so the generation is reproducible from the repo alone. Future regenerations replace it and re-run the script.
Task 3: srccfg StreamLanguage mode
Files:
-
Create:
l4d2web/scripts/editor-src/srccfg-mode.js -
Step 1: Write the mode
import { StreamLanguage } from "@codemirror/language";
// Source-engine .cfg syntax (server.cfg style).
// Linewise. No nesting. Tokens: comment, string, number, keyword, identifier.
const KEYWORDS = new Set(["exec", "alias", "bind", "unbindall", "wait"]);
export const srccfgLanguage = StreamLanguage.define({
name: "srccfg",
startState: () => ({}),
token(stream) {
if (stream.eatSpace()) return null;
if (stream.match("//")) {
stream.skipToEnd();
return "comment";
}
if (stream.match(/^"(?:[^"\\]|\\.)*"?/)) return "string";
if (stream.match(/^-?\d+(?:\.\d+)?/)) return "number";
if (stream.match(/^[A-Za-z_][A-Za-z0-9_]*/)) {
const word = stream.current();
return KEYWORDS.has(word) ? "keyword" : "variableName";
}
stream.next();
return null;
},
languageData: { commentTokens: { line: "//" } },
});
- Step 2: Smoke-build to verify the import resolves
From l4d2web/scripts/editor-src/:
node --input-type=module -e 'import("./srccfg-mode.js").then(m => console.log(Object.keys(m)))'
Expected: [ 'srccfgLanguage' ] (no thrown error).
- Step 3: Commit
git add l4d2web/scripts/editor-src/srccfg-mode.js
git commit -m "feat(editor-v2): srccfg StreamLanguage mode"
Task 4: Light + dark themes
Files:
- Create:
l4d2web/scripts/editor-src/themes.js
cm6's EditorView.theme() produces an Extension. We want two themes
exposed via a Compartment so the active one is swappable.
- Step 1: Write
themes.js
import { EditorView } from "@codemirror/view";
// CSS variables are defined in static/css/tokens.css (light) and the
// `prefers-color-scheme: dark` block. Both themes route through the
// same --cm-* variable names; the OS toggle swaps the underlying values.
//
// Two named themes so we can also force-pick light/dark in tests if needed.
const baseRules = {
"&": {
backgroundColor: "var(--cm-bg)",
color: "var(--cm-fg)",
fontFamily: "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)",
fontSize: "14px",
},
".cm-content": { caretColor: "var(--cm-fg)", padding: "8px" },
".cm-cursor": { borderLeftColor: "var(--cm-fg)" },
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection": {
backgroundColor: "var(--cm-selection)",
},
".cm-gutters": {
backgroundColor: "var(--cm-bg)",
color: "var(--fg-muted, #888)",
border: "none",
},
".cm-tooltip": {
backgroundColor: "var(--cm-bg)",
border: "1px solid var(--border-strong, #444)",
color: "var(--cm-fg)",
},
".cm-tooltip-autocomplete > ul > li[aria-selected]": {
backgroundColor: "var(--cm-selection)",
},
// Syntax token colors come from highlightStyle below.
};
export const editorLightTheme = EditorView.theme(baseRules, { dark: false });
export const editorDarkTheme = EditorView.theme(baseRules, { dark: true });
- Step 2: Add the highlight style for syntax tokens
Append to themes.js:
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { tags as t } from "@lezer/highlight";
export const editorHighlightStyle = HighlightStyle.define([
{ tag: t.comment, color: "var(--cm-comment)" },
{ tag: t.string, color: "var(--cm-string)" },
{ tag: t.number, color: "var(--cm-number)" },
{ tag: t.keyword, color: "var(--cm-keyword)" },
{ tag: t.variableName, color: "var(--cm-fg)" },
]);
export const editorHighlighting = syntaxHighlighting(editorHighlightStyle);
@lezer/highlight is a transitive dep of @codemirror/language; no need to add it to package.json. esbuild will resolve it.
- Step 3: Verify the imports resolve
From editor-src/:
node --input-type=module -e 'import("./themes.js").then(m => console.log(Object.keys(m).sort()))'
Expected: [ 'editorDarkTheme', 'editorHighlightStyle', 'editorHighlighting', 'editorLightTheme' ].
- Step 4: Commit
git add l4d2web/scripts/editor-src/themes.js
git commit -m "feat(editor-v2): light + dark themes + syntax highlight style"
Task 5: Autocomplete completion source
Files:
-
Create:
l4d2web/scripts/editor-src/autocomplete.js -
Step 1: Write the completion source
import { autocompletion } from "@codemirror/autocomplete";
const WORD_RE = /[A-Za-z0-9_]{2,}/;
function rank(query, label) {
const q = query.toLowerCase();
const l = label.toLowerCase();
if (l === q) return 0;
if (l.startsWith(q)) return 1 + l.length; // shorter prefix matches first
const i = l.indexOf(q);
if (i !== -1) return 10000 + i; // substring matches after all prefix matches
return -1;
}
export function vocabCompletions(vocab) {
// vocab: { cvars: [{name, desc?}, …], commands: [{name, desc?}, …] }
const entries = [
...vocab.cvars.map(e => ({ ...e, kind: "cvar" })),
...vocab.commands.map(e => ({ ...e, kind: "command" })),
];
return (context) => {
const word = context.matchBefore(WORD_RE);
if (!word || (word.from === word.to && !context.explicit)) return null;
const q = word.text;
const scored = [];
for (const e of entries) {
const r = rank(q, e.name);
if (r === -1) continue;
scored.push([r, e]);
if (scored.length > 200) break; // bound work; we cap to 50 below
}
scored.sort((a, b) => a[0] - b[0]);
const options = scored.slice(0, 50).map(([, e]) => ({
label: e.name,
info: e.desc || e.kind,
type: e.kind === "command" ? "function" : "variable",
}));
return { from: word.from, options, validFor: WORD_RE };
};
}
export function autocompleteExtension(vocab) {
return autocompletion({
override: [vocabCompletions(vocab)],
activateOnTyping: true,
maxRenderedOptions: 8,
});
}
- Step 2: Sanity-check exports
From editor-src/:
node --input-type=module -e 'import("./autocomplete.js").then(m => console.log(Object.keys(m).sort()))'
Expected: [ 'autocompleteExtension', 'vocabCompletions' ].
- Step 3: Commit
git add l4d2web/scripts/editor-src/autocomplete.js
git commit -m "feat(editor-v2): autocomplete completion source"
Task 6: Editor entry façade (editor-entry.js)
Files:
-
Modify:
l4d2web/scripts/editor-src/editor-entry.js(replaces the stub from Task 1) -
Step 1: Write the façade
import { EditorState, Compartment } from "@codemirror/state";
import { EditorView, keymap, lineNumbers, highlightActiveLine } from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { StreamLanguage, indentOnInput, bracketMatching, defaultHighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { closeBrackets, closeBracketsKeymap, completionKeymap } from "@codemirror/autocomplete";
import { shell as shellLegacy } from "@codemirror/legacy-modes/mode/shell";
import { srccfgLanguage } from "./srccfg-mode.js";
import { editorLightTheme, editorDarkTheme, editorHighlighting } from "./themes.js";
import { autocompleteExtension } from "./autocomplete.js";
const bashLanguage = StreamLanguage.define(shellLegacy);
function pickLanguage(name) {
if (name === "srccfg") return srccfgLanguage;
if (name === "bash") return bashLanguage;
return null; // "plain" / unknown → no language extension
}
function pickThemeForMatchMedia(mm) {
return mm.matches ? editorDarkTheme : editorLightTheme;
}
function mount(textarea, { language = "plain", vocab = null } = {}) {
const langCompartment = new Compartment();
const themeCompartment = new Compartment();
const autocompleteCompartment = new Compartment();
const lang = pickLanguage(language);
const mm = window.matchMedia("(prefers-color-scheme: dark)");
const extensions = [
history(),
lineNumbers(),
highlightActiveLine(),
bracketMatching(),
closeBrackets(),
indentOnInput(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
editorHighlighting,
themeCompartment.of(pickThemeForMatchMedia(mm)),
langCompartment.of(lang ? [lang] : []),
autocompleteCompartment.of(vocab ? [autocompleteExtension(vocab)] : []),
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...historyKeymap,
...completionKeymap,
indentWithTab,
]),
];
const state = EditorState.create({ doc: textarea.value, extensions });
const view = new EditorView({ state, parent: textarea.parentElement });
// Insert the editor right before the textarea, then hide the textarea.
textarea.parentElement.insertBefore(view.dom, textarea);
textarea.style.display = "none";
// OS-level theme swap
const onThemeChange = () => view.dispatch({
effects: themeCompartment.reconfigure(pickThemeForMatchMedia(mm)),
});
mm.addEventListener("change", onThemeChange);
const controller = {
getValue: () => view.state.doc.toString(),
setContent: (text) => {
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } });
},
setLanguage: (name) => {
const next = pickLanguage(name);
view.dispatch({ effects: langCompartment.reconfigure(next ? [next] : []) });
},
destroy: () => {
mm.removeEventListener("change", onThemeChange);
view.destroy();
textarea.style.display = "";
},
};
return controller;
}
window.__editor = { mount };
- Step 2: Smoke import
From editor-src/:
node --input-type=module -e 'import("./editor-entry.js").catch(e => { console.error(e.message); process.exit(1); })'
Expected: errors out on window is not defined (Node has no DOM) — but the imports must resolve before that error. If you see Cannot find module … that's a real failure to fix; the window is not defined ReferenceError is fine and proves the module graph resolved.
- Step 3: Commit
git add l4d2web/scripts/editor-src/editor-entry.js
git commit -m "feat(editor-v2): editor-entry façade wiring all extensions"
Task 7: Build script + first successful bundle
Files:
-
Create:
l4d2web/scripts/build-editor.sh -
Create:
l4d2web/l4d2web/static/vendor/editor.bundle.js(committed artifact) -
Create:
l4d2web/l4d2web/static/vendor/editor.bundle.css(committed artifact) -
Create:
l4d2web/l4d2web/static/vendor/editor.bundle.sha256 -
Create:
l4d2web/l4d2web/static/vendor/README.md -
Step 1: Write
build-editor.sh
Path: l4d2web/scripts/build-editor.sh — chmod 755.
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SRC="$HERE/editor-src"
OUT="$HERE/../l4d2web/static/vendor"
cd "$SRC"
npm ci
npx esbuild editor-entry.js \
--bundle --minify \
--format=iife \
--global-name=__editor_pkg \
--outfile="$OUT/editor.bundle.js" \
--metafile=meta.json \
--loader:.css=text
# (CSS is currently injected by cm6 at runtime via its own DOM machinery;
# we do not need a separate editor.bundle.css for cm6 itself. If a future
# extension requires extracted CSS, switch to --loader:.css=copy.)
# Touch an empty .css so the partial template can <link> it harmlessly.
: > "$OUT/editor.bundle.css"
(cd "$OUT" && sha256sum editor.bundle.js editor.bundle.css > editor.bundle.sha256)
echo "Built $OUT/editor.bundle.js ($(wc -c < "$OUT/editor.bundle.js") bytes)"
- Step 2: Run it
From repo root: ./l4d2web/scripts/build-editor.sh
Expected stdout ends with Built …/editor.bundle.js (NNNNNN bytes) where N is roughly 200000–400000 (cm6 minified).
- Step 3: Verify the bundle exposes the global
node -e 'const fs = require("fs"); const code = fs.readFileSync("l4d2web/l4d2web/static/vendor/editor.bundle.js", "utf8"); eval(code); console.log(typeof __editor_pkg, typeof window.__editor.mount);'
You'll need a global.window = global shim — instead run:
node -e 'global.window = global; global.matchMedia = () => ({matches:false, addEventListener(){}, removeEventListener(){}}); eval(require("fs").readFileSync("l4d2web/l4d2web/static/vendor/editor.bundle.js","utf8")); console.log(typeof window.__editor, typeof window.__editor.mount);'
Expected: object function.
- Step 4: Write the vendor README
l4d2web/l4d2web/static/vendor/README.md:
# Editor bundle vendor README
`editor.bundle.js` is a pre-built IIFE produced by esbuild from
`l4d2web/scripts/editor-src/`. It exposes `window.__editor.mount(textarea, opts)`.
## Rebuild
From repo root:
./l4d2web/scripts/build-editor.sh
This runs `npm ci` inside `editor-src/` then `npx esbuild`. The
output overwrites `editor.bundle.js` and `editor.bundle.css` in this
directory and refreshes `editor.bundle.sha256`.
## Pinned dependencies
See `l4d2web/scripts/editor-src/package.json` for semver ranges and
`package-lock.json` for the exact resolved versions. Run
`npm outdated` inside `editor-src/` to see upgrade candidates.
## Integrity
`editor.bundle.sha256` contains the hashes of the committed bundle.
If the bundle drifts from this hash in CI / review, the artifact was
rebuilt without committing the updated bundle.
- Step 5: Commit
git add l4d2web/scripts/build-editor.sh \
l4d2web/l4d2web/static/vendor/editor.bundle.js \
l4d2web/l4d2web/static/vendor/editor.bundle.css \
l4d2web/l4d2web/static/vendor/editor.bundle.sha256 \
l4d2web/l4d2web/static/vendor/README.md
git commit -m "feat(editor-v2): build script + first bundle output"
Task 8: tokens.css syntax variables + editor.css bridge
Files:
-
Modify:
l4d2web/l4d2web/static/css/tokens.css(add--syntax-*+--cm-*in default andprefers-color-scheme: darkblocks) -
Create:
l4d2web/l4d2web/static/css/editor.css -
Step 1: Read the current tokens.css to find the right insertion points
cat l4d2web/l4d2web/static/css/tokens.css
You're looking for the :root block (default = light palette) and the
@media (prefers-color-scheme: dark) block at line 38.
- Step 2: Add light-mode variables to the
:rootblock
Insert before the closing } of the default :root block. Pick
existing variable names where they exist (e.g. --fg-primary,
--bg-surface); otherwise use literal colors.
/* Editor (CodeMirror 6) palette — light. */
--cm-bg: var(--bg-surface, #ffffff);
--cm-fg: var(--fg-primary, #222);
--cm-selection: rgba(60, 130, 220, 0.2);
--syntax-keyword: #cc4488;
--syntax-string: #2f8b3a;
--syntax-comment: #888;
--syntax-number: #884488;
--cm-keyword: var(--syntax-keyword);
--cm-string: var(--syntax-string);
--cm-comment: var(--syntax-comment);
--cm-number: var(--syntax-number);
- Step 3: Add dark-mode overrides inside the
@media (prefers-color-scheme: dark)block
--cm-bg: var(--bg-surface, #181a1c);
--cm-fg: var(--fg-primary, #e8e8e8);
--cm-selection: rgba(120, 170, 255, 0.25);
--syntax-keyword: #ff80c0;
--syntax-string: #87d96a;
--syntax-comment: #888;
--syntax-number: #c890ff;
(--cm-* aliases re-bind through tokens.css cascade — no need to
restate them in the dark block; the underlying --syntax-* change
propagates.)
- Step 4: Write
editor.css
/* Bridges cm6 internal classes to the variables declared in tokens.css.
* Most styling is done in themes.js; this file scopes the editor shell
* itself + layout sizing the per-call-site templates expect. */
.editor-shell, .cm-editor {
border: 1px solid var(--border, #ccc);
border-radius: 4px;
min-height: 8em;
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
}
.cm-editor.cm-focused {
outline: 2px solid var(--focus-ring, #4a90e2);
outline-offset: -2px;
}
textarea[data-editor-language] + .cm-editor {
width: 100%;
}
- Step 5: Commit
git add l4d2web/l4d2web/static/css/tokens.css l4d2web/l4d2web/static/css/editor.css
git commit -m "feat(editor-v2): tokens.css syntax vars + editor.css shell"
Task 9: Editor.js glue (mount loop, submit-capture, __filesEditor alias)
Files:
-
Create:
l4d2web/l4d2web/static/js/editor.js -
Step 1: Write
editor.js
// Un-bundled. Driven by data-editor-language attrs on <textarea>.
// Mounts cm6 (from editor.bundle.js exporting window.__editor),
// installs one capture-phase submit handler per <form>, and exposes
// a named alias for the files-editor modal.
(function () {
"use strict";
if (!window.__editor || typeof window.__editor.mount !== "function") {
return; // bundle didn't load — graceful no-JS fallback
}
let vocabPromise = null;
function loadSrccfgVocab() {
if (!vocabPromise) {
vocabPromise = fetch("/static/data/srccfg-vocab.json", { credentials: "same-origin" })
.then(r => r.ok ? r.json() : Promise.reject(new Error("vocab fetch failed: " + r.status)))
.catch(err => { console.warn("[editor] vocab load failed", err); return null; });
}
return vocabPromise;
}
function resolveAutoLanguage(filenameInput) {
const name = (filenameInput && filenameInput.value || "").toLowerCase();
if (name.endsWith(".cfg")) return "srccfg";
if (name.endsWith(".sh")) return "bash";
return "plain";
}
async function mountOne(textarea) {
let lang = textarea.getAttribute("data-editor-language") || "plain";
let filenameInput = null;
let dropdown = null;
if (lang === "auto") {
const modal = textarea.closest(".files-editor") || document;
filenameInput = modal.querySelector("[data-editor-filename]");
dropdown = modal.querySelector("[data-editor-language-select]");
lang = resolveAutoLanguage(filenameInput);
}
const vocab = (lang === "srccfg") ? await loadSrccfgVocab() : null;
const controller = window.__editor.mount(textarea, { language: lang, vocab });
// Submit-time copy bridge
const form = textarea.closest("form");
if (form && !form.__editorSubmitBound) {
form.__editorSubmitBound = true;
form.addEventListener("submit", () => {
for (const ta of form.querySelectorAll("textarea[data-editor-language]")) {
if (ta.__editorController) ta.value = ta.__editorController.getValue();
}
}, true /* capture phase */);
}
textarea.__editorController = controller;
// Files-modal hooks
if (textarea.classList.contains("files-editor-content")) {
window.__filesEditor = controller;
if (dropdown) {
dropdown.addEventListener("change", () => {
const v = dropdown.value;
controller.setLanguage(v === "auto" ? resolveAutoLanguage(filenameInput) : v);
});
}
if (filenameInput) {
filenameInput.addEventListener("input", () => {
if (!dropdown || dropdown.value === "auto") {
controller.setLanguage(resolveAutoLanguage(filenameInput));
}
});
}
}
}
function init() {
for (const ta of document.querySelectorAll("textarea[data-editor-language]")) {
mountOne(ta).catch(err => console.error("[editor] mount failed", err));
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
- Step 2: Lint-by-eye for one thing
Confirm the submit listener is capture-phase (true argument) and runs before the browser default form serialization. It must be capture-phase: a bubble-phase listener runs after the browser has already read textarea.value for the POST body.
- Step 3: Commit
git add l4d2web/l4d2web/static/js/editor.js
git commit -m "feat(editor-v2): editor.js glue (mount, submit-capture, files alias)"
Task 10: _editor_assets.html Jinja partial
Files:
-
Create:
l4d2web/l4d2web/templates/_editor_assets.html -
Step 1: Write the partial
{# Editor assets — include on any page that mounts a <textarea data-editor-language>. #}
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/editor.bundle.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/editor.css') }}">
<script src="{{ url_for('static', filename='vendor/editor.bundle.js') }}" nonce="{{ g.csp_nonce }}" defer></script>
<script src="{{ url_for('static', filename='js/editor.js') }}" nonce="{{ g.csp_nonce }}" defer></script>
defer ensures editor.bundle.js (which sets window.__editor) executes
before editor.js (which reads it), since defer scripts preserve
document order.
- Step 2: Commit
git add l4d2web/l4d2web/templates/_editor_assets.html
git commit -m "feat(editor-v2): _editor_assets.html Jinja partial"
Task 11: Form-contract pytest (pre-wiring gate)
Files:
- Modify:
l4d2web/tests/test_blueprints.py - Modify:
l4d2web/tests/test_script_overlay_routes.py
These tests pin the form-POST contract that the editor must preserve. They should pass today, and continue passing after Task 12's template wiring.
- Step 1: Read the current shape
rg -n "def test_" l4d2web/tests/test_blueprints.py | head -20
rg -n "def test_" l4d2web/tests/test_script_overlay_routes.py | head -20
Confirm the existing tests use a client fixture and the seeded user / blueprint shape.
- Step 2: Add the form-contract test to
test_blueprints.py
Append (adapt fixtures to match what's already there):
def test_blueprint_config_form_post_contract(client, login_dev, demo_blueprint):
"""The blueprint detail form must accept POST with `config` field
and persist verbatim — pinned regardless of editor-bundle changes."""
config_value = "// pinned by form-contract test\nsv_cheats 0\n"
resp = client.post(
f"/blueprints/{demo_blueprint.id}",
data={"name": demo_blueprint.name, "config": config_value, "arguments": ""},
follow_redirects=False,
)
assert resp.status_code in (302, 303), resp.data
# Reload and verify persistence
from l4d2web.models import Blueprint
refreshed = client.application.extensions["sqlalchemy"].db.session.get(Blueprint, demo_blueprint.id)
assert refreshed.config == config_value
Adapt the imports + how to load Blueprint from the session to match
the test file's existing conventions. If a similar fixture
(login_dev, demo_blueprint) doesn't exist yet, write one in
conftest.py modeled on the e2e live_server seeding.
- Step 3: Add the form-contract test to
test_script_overlay_routes.py
Same shape:
def test_overlay_script_form_post_contract(client, login_dev, demo_script_overlay):
script_value = "#!/usr/bin/env bash\necho pinned\n"
resp = client.post(
f"/overlays/{demo_script_overlay.id}",
data={"name": demo_script_overlay.name, "script": script_value},
follow_redirects=False,
)
assert resp.status_code in (302, 303), resp.data
# Reload and verify
from l4d2web.models import Overlay
refreshed = client.application.extensions["sqlalchemy"].db.session.get(Overlay, demo_script_overlay.id)
assert refreshed.script == script_value
- Step 4: Run
From l4d2web/: uv run pytest tests/test_blueprints.py::test_blueprint_config_form_post_contract tests/test_script_overlay_routes.py::test_overlay_script_form_post_contract -v
Expected: 2 passed (today, before any template change).
- Step 5: Commit
git add l4d2web/tests/test_blueprints.py l4d2web/tests/test_script_overlay_routes.py
git commit -m "test(editor-v2): pin form-POST contract before wiring"
Task 12: Template wiring (the three textareas)
Files:
- Modify:
l4d2web/l4d2web/templates/blueprint_detail.html(line 52) - Modify:
l4d2web/l4d2web/templates/overlay_detail.html(lines 25, 178) - Modify:
l4d2web/tests/test_blueprints.py(add GET-asserts-markup test) - Modify:
l4d2web/tests/test_script_overlay_routes.py(same)
TDD: write the GET assertion test, see it fail, edit templates, see it pass.
- Step 1: Write the failing GET test in
test_blueprints.py
def test_blueprint_get_includes_editor_markup(client, login_dev, demo_blueprint):
resp = client.get(f"/blueprints/{demo_blueprint.id}")
assert resp.status_code == 200
body = resp.data.decode()
assert 'data-editor-language="srccfg"' in body
assert 'vendor/editor.bundle.js' in body
assert 'js/editor.js' in body
- Step 2: Run it, watch it fail
From l4d2web/: uv run pytest tests/test_blueprints.py::test_blueprint_get_includes_editor_markup -v
Expected: FAIL with assert 'data-editor-language="srccfg"' in body.
- Step 3: Add the attr + partial to
blueprint_detail.html
Find line 52:
<textarea name="config" rows="8" spellcheck="false">{{ config_lines | join('\n') }}</textarea>
Change to:
<textarea name="config" rows="8" spellcheck="false" data-editor-language="srccfg">{{ config_lines | join('\n') }}</textarea>
Find the {% block extra_head %} (or equivalent) block and add:
{% include "_editor_assets.html" %}
If no such block exists, include just before the closing </body> or at the bottom of {% block content %}.
- Step 4: Re-run the test, watch it pass
uv run pytest tests/test_blueprints.py::test_blueprint_get_includes_editor_markup -v
Expected: PASS.
- Step 5: Mirror tests + edits for
overlay_detail.html
Add to test_script_overlay_routes.py:
def test_overlay_script_get_includes_editor_markup(client, login_dev, demo_script_overlay):
resp = client.get(f"/overlays/{demo_script_overlay.id}")
body = resp.data.decode()
assert 'data-editor-language="bash"' in body
assert 'vendor/editor.bundle.js' in body
def test_overlay_files_get_includes_editor_markup(client, login_dev, demo_files_overlay):
resp = client.get(f"/overlays/{demo_files_overlay.id}")
body = resp.data.decode()
assert 'data-editor-language="auto"' in body
assert 'data-editor-language-select' in body
assert 'data-editor-filename' in body
Run, watch fail.
Edit overlay_detail.html:
- Line 25 textarea: add
data-editor-language="bash". - Line 178 textarea: add
data-editor-language="auto"andclass="files-editor-content"(the latter already exists per current source — verify). Add a sibling<select data-editor-language-select>with optionsauto, srccfg, bash, plain, and adddata-editor-filenameto the existing filename input. - Include
_editor_assets.htmlpartial.
Re-run, watch pass.
- Step 6: Re-run the form-contract tests
Verify Task 11's tests still pass with the new attrs:
uv run pytest tests/test_blueprints.py tests/test_script_overlay_routes.py -v
Expected: all green, including the new markup tests.
- Step 7: Commit
git add l4d2web/l4d2web/templates/blueprint_detail.html \
l4d2web/l4d2web/templates/overlay_detail.html \
l4d2web/tests/test_blueprints.py \
l4d2web/tests/test_script_overlay_routes.py
git commit -m "feat(editor-v2): wire data-editor-language attrs into 3 textareas"
Task 13: files-overlay.js bridge swap
Files:
- Modify:
l4d2web/l4d2web/static/js/files-overlay.js(5 reads + 3 writes)
The files-editor modal currently reads/writes the textarea directly.
Switch every site to the editor controller via the window.__filesEditor
alias from Task 9.
- Step 1: Define a helper at the top of files-overlay.js
Near the top of the module (after the existing IIFE opener):
function getEditorValue() {
return (window.__filesEditor && window.__filesEditor.getValue)
? window.__filesEditor.getValue()
: editorEls.contentBox.value; // no-JS / bundle-failed fallback
}
function setEditorValue(text) {
if (window.__filesEditor && window.__filesEditor.setContent) {
window.__filesEditor.setContent(text);
} else {
editorEls.contentBox.value = text;
}
}
(Place these in scope where editorEls is accessible.)
- Step 2: Replace each read
At lines 289, 464, 479, 494, 511: editorEls.contentBox.value →
getEditorValue().
- Step 3: Replace each write
At lines 345, 378, 385: editorEls.contentBox.value = X →
setEditorValue(X).
- Step 4: Verify no remaining direct accesses
rg -n "contentBox\.value" l4d2web/l4d2web/static/js/files-overlay.js
Expected: only the two fallback paths inside getEditorValue /
setEditorValue.
- Step 5: Local manual smoke
./scripts/dev-server.py
Visit http://127.0.0.1:5051/login, log in as dev/devdevdev,
visit /overlays/2 (files overlay), open test.cfg in the editor.
Verify:
-
Content loads.
-
Editing produces highlighted srccfg tokens.
-
Save round-trips via
fetch('/files/save')(check Network tab). -
Switching the language
<select>tobashre-highlights. -
Step 6: Commit
git add l4d2web/l4d2web/static/js/files-overlay.js
git commit -m "feat(editor-v2): files-overlay reads/writes via window.__filesEditor"
Task 14: Playwright e2e test (test_editor.py)
Files:
-
Create:
l4d2web/tests/e2e/test_editor.py -
Modify:
l4d2web/tests/e2e/conftest.pyif thelive_serverfixture needs to seed a.cfg-content blueprint. -
Step 1: Check the existing
live_serverfixture
cat l4d2web/tests/e2e/conftest.py
Confirm it seeds a blueprint with srccfg-shaped content (the dev-server script already does — copy that seeding if not already in the conftest).
- Step 2: Write the e2e test
import pytest
from playwright.sync_api import Page, expect
pytestmark = pytest.mark.e2e
def _login(page: Page, base_url: str) -> None:
page.goto(f"{base_url}/login")
page.fill('input[name="username"]', "dev")
page.fill('input[name="password"]', "devdevdev")
page.click('button[type="submit"]')
def test_blueprint_autocomplete_accept_and_submit(page: Page, live_server) -> None:
base = live_server["base_url"]
bp_id = live_server["blueprint_id"]
_login(page, base)
page.goto(f"{base}/blueprints/{bp_id}")
editor = page.locator(".cm-content")
editor.click()
page.keyboard.press("Control+End")
page.keyboard.type("\nsv_che")
popup = page.locator(".cm-tooltip-autocomplete")
expect(popup).to_be_visible(timeout=2000)
expect(popup).to_contain_text("sv_cheats")
page.keyboard.press("Tab") # accept
# Submit and assert persistence via the hidden textarea
page.evaluate("""() => {
const ta = document.querySelector('textarea[name="config"]');
const form = ta.closest('form');
form.requestSubmit();
}""")
# After redirect, reload and confirm the value persisted
page.goto(f"{base}/blueprints/{bp_id}")
ta_value = page.evaluate('() => document.querySelector(\'textarea[name="config"]\').value')
assert "sv_cheats" in ta_value
def test_copy_preserves_newlines(page: Page, live_server) -> None:
"""Regression gate for bug class 1 from the v1 attempt.
cm6 handles multi-line copy correctly out of the box; this test
pins that behavior so a future change doesn't regress it."""
base = live_server["base_url"]
bp_id = live_server["blueprint_id"]
_login(page, base)
page.goto(f"{base}/blueprints/{bp_id}")
editor = page.locator(".cm-content")
editor.click()
page.keyboard.press("Control+A")
# Read the selection via the page's Selection API (clipboard-permissions
# are fiddly in CI; Selection.toString() is enough to verify the
# underlying linebreak preservation cm6 guarantees).
selected = page.evaluate("() => window.getSelection().toString()")
assert selected.count("\n") >= 1, f"expected multi-line selection, got: {selected!r}"
- Step 3: Run it
From l4d2web/:
uv run pytest -m e2e tests/e2e/test_editor.py -v
(Run with dangerouslyDisableSandbox: true on Claude Code's Bash tool — Chromium's Mach-port IPC is sandbox-blocked.)
Expected: both tests pass. If the first fails on popup visibility, check that srccfg-vocab.json is fetchable (network tab) and that sv_cheats is actually in the vocab.
- Step 4: Commit
git add l4d2web/tests/e2e/test_editor.py l4d2web/tests/e2e/conftest.py
git commit -m "test(editor-v2): Playwright e2e + copy regression gate"
Task 15: Docs + final smoke
Files:
-
Modify:
AGENTS.md(add Node + npm + build script invocation) -
Verify: dev-server smoke against all three textareas
-
Step 1: Inspect existing AGENTS.md edits before touching it
AGENTS.md is already M from before this branch started. Inspect:
git diff AGENTS.md
Add the new content alongside, don't clobber.
- Step 2: Add an editor-rebuild section to AGENTS.md
Append (adjust to match existing section structure):
### Editor bundle (CodeMirror 6)
The in-browser code editor on blueprint config / overlay script / files
modal is bundled from `l4d2web/scripts/editor-src/` via esbuild. To
rebuild after editing the source:
./l4d2web/scripts/build-editor.sh
Requires `node` + `npm` (one-time `npm ci` happens inside the script).
Output overwrites `l4d2web/l4d2web/static/vendor/editor.bundle.{js,css}`
and refreshes `editor.bundle.sha256`. Commit all four files.
Vocab regeneration (after replacing `./cvar_list`):
./l4d2web/scripts/build-vocab.py
- Step 3: End-to-end local smoke
rm -rf .tmp/dev-server # idempotent reseed
./scripts/dev-server.py
Visit each of /blueprints/1, /overlays/1, /overlays/2 and verify:
| Page | Expected |
|---|---|
/blueprints/1 (demo-srccfg) |
cm6 mounts; srccfg syntax highlighted; typing sv_che opens autocomplete with sv_cheats; Tab accepts; form Save persists |
/overlays/1 (demo-bash) |
cm6 mounts; bash syntax highlighted; form Save persists |
/overlays/2 (demo-files) |
cm6 mounts; opening test.cfg shows srccfg highlighting; language <select> switches to bash and re-highlights; Save round-trips via /files/save |
Toggle the OS color scheme during the smoke; verify the editor re-skins.
- Step 4: Run the full test matrix
cd l4d2web && uv run pytest # fast suite
cd l4d2web && uv run pytest -m e2e # e2e suite
Both green.
- Step 5: Commit
git add AGENTS.md
git commit -m "docs(editor-v2): AGENTS.md update + final smoke pass"
Self-review (done by plan author)
1. Spec coverage:
| Spec section | Covered by |
|---|---|
| §1 Context / handoff link | (referenced in plan header) |
| §2 Goal | Plan goal line |
| §3 Architecture / form-bridge | Tasks 6, 9 (façade + submit-capture) |
| §3 Subsystems table | Tasks 1–10 (one per row) |
| §4 Call sites | Task 12 (template wiring) |
| §5 Editor module contract | Task 6 (editor-entry.js) |
| §6 Languages | Tasks 3 (srccfg), 6 (bash via legacy shell) |
| §7 Autocomplete | Task 5 |
| §8 Vocabulary | Task 2 |
| §9 Theme (light/dark via prefers-color-scheme) | Tasks 4, 6 (matchMedia compartment), 8 (CSS vars) |
| §10 Build pipeline | Tasks 1, 7 |
| §11 CSP & asset layout | Task 10 (_editor_assets.html partial with nonces) |
| §12 Files touched | All tasks |
| §13 Test strategy | Tasks 11, 12, 14 |
| §14 Verification | Task 15 |
| §15 Out of scope | Honored (no line-numbers extension, no search, etc.) |
2. Placeholder scan: none — every code/CSS/HTML block is concrete.
3. Type consistency: controller shape (getValue / setContent / setLanguage / destroy) is consistent across Tasks 6, 9, 13. __editor.mount signature is consistent. The named alias is consistently window.__filesEditor.
4. Risk callouts left to executing engineer's judgement:
- Exact resolved cm6 minor versions from
npm installmay differ from the^6.x.yranges inpackage.json— that's the point of committing the lockfile. The bundle size + bundle hash will be deterministic per lockfile. - The line numbers cited for
files-overlay.js(289, 464, …) are based on the current HEAD. If a prior unrelated commit shifts them, re-runrg -n "contentBox\.value" l4d2web/l4d2web/static/js/files-overlay.jsand edit those sites. - If the actual
:rootblock intokens.cssdoesn't have--bg-surface/--fg-primary, fall back to the literal colors named in Task 8's CSS via the var()'s default branch — already wired in.
Execution handoff
Plan complete. Spec at docs/superpowers/specs/2026-05-17-textarea-editor-v2-design.md; plan at docs/superpowers/plans/2026-05-17-textarea-editor-v2.md. Two execution options:
- Subagent-Driven (recommended) — fresh subagent per task; review between tasks; uses
superpowers:subagent-driven-development. - Inline Execution — execute tasks in this session via
superpowers:executing-planswith checkpoints.