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>
1351 lines
46 KiB
Markdown
1351 lines
46 KiB
Markdown
# 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`**
|
||
|
||
```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`**
|
||
|
||
```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**
|
||
|
||
```bash
|
||
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.
|
||
|
||
```python
|
||
#!/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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```js
|
||
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/`:
|
||
```bash
|
||
node --input-type=module -e 'import("./srccfg-mode.js").then(m => console.log(Object.keys(m)))'
|
||
```
|
||
Expected: `[ 'srccfgLanguage' ]` (no thrown error).
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
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`**
|
||
|
||
```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`:
|
||
|
||
```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/`:
|
||
```bash
|
||
node --input-type=module -e 'import("./themes.js").then(m => console.log(Object.keys(m).sort()))'
|
||
```
|
||
Expected: `[ 'editorDarkTheme', 'editorHighlightStyle', 'editorHighlighting', 'editorLightTheme' ]`.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```js
|
||
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/`:
|
||
```bash
|
||
node --input-type=module -e 'import("./autocomplete.js").then(m => console.log(Object.keys(m).sort()))'
|
||
```
|
||
Expected: `[ 'autocompleteExtension', 'vocabCompletions' ]`.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```js
|
||
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/`:
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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.
|
||
|
||
```bash
|
||
#!/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**
|
||
|
||
```bash
|
||
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:
|
||
```bash
|
||
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`:
|
||
|
||
```markdown
|
||
# 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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.
|
||
|
||
```css
|
||
/* 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**
|
||
|
||
```css
|
||
--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`**
|
||
|
||
```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**
|
||
|
||
```bash
|
||
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`**
|
||
|
||
```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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```html
|
||
{# 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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):
|
||
|
||
```python
|
||
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:
|
||
|
||
```python
|
||
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**
|
||
|
||
```bash
|
||
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`**
|
||
|
||
```python
|
||
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:
|
||
```html
|
||
<textarea name="config" rows="8" spellcheck="false">{{ config_lines | join('\n') }}</textarea>
|
||
```
|
||
Change to:
|
||
```html
|
||
<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:
|
||
```html
|
||
{% 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`:
|
||
|
||
```python
|
||
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**
|
||
|
||
```bash
|
||
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):
|
||
|
||
```js
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
./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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```python
|
||
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/`:
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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):
|
||
|
||
```markdown
|
||
### 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
cd l4d2web && uv run pytest # fast suite
|
||
cd l4d2web && uv run pytest -m e2e # e2e suite
|
||
```
|
||
Both green.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
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 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.
|