left4me/docs/superpowers/plans/2026-05-17-textarea-editor-v2.md
mwiegand ebf6d2ebc6
plan(textarea-editor-v2): bite-sized TDD implementation plan
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>
2026-05-17 01:41:26 +02:00

46 KiB
Raw Blame History

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_listsrccfg-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 200000400000 (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 and prefers-color-scheme: dark blocks)

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

  • Step 1: Read the current tokens.css to find the right insertion points

cat l4d2web/l4d2web/static/css/tokens.css

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

  • Step 2: Add light-mode variables to the :root block

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

/* 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" and class="files-editor-content" (the latter already exists per current source — verify). Add a sibling <select data-editor-language-select> with options auto, srccfg, bash, plain, and add data-editor-filename to the existing filename input.
  • Include _editor_assets.html partial.

Re-run, watch pass.

  • Step 6: Re-run the form-contract tests

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

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

  • Step 7: Commit
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.valuegetEditorValue().

  • Step 3: Replace each write

At lines 345, 378, 385: editorEls.contentBox.value = XsetEditorValue(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> to bash re-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.py if the live_server fixture needs to seed a .cfg-content blueprint.

  • Step 1: Check the existing live_server fixture

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 110 (one per row)
§4 Call sites Task 12 (template wiring)
§5 Editor module contract Task 6 (editor-entry.js)
§6 Languages Tasks 3 (srccfg), 6 (bash via legacy shell)
§7 Autocomplete Task 5
§8 Vocabulary Task 2
§9 Theme (light/dark via prefers-color-scheme) Tasks 4, 6 (matchMedia compartment), 8 (CSS vars)
§10 Build pipeline Tasks 1, 7
§11 CSP & asset layout Task 10 (_editor_assets.html partial with nonces)
§12 Files touched All tasks
§13 Test strategy Tasks 11, 12, 14
§14 Verification Task 15
§15 Out of scope Honored (no line-numbers extension, no search, etc.)

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

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

4. Risk callouts left to executing engineer's judgement:

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

Execution handoff

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

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