# Textarea Code Editor v2 (CodeMirror 6) — Design

**Status:** Approved 2026-05-17. Supersedes `2026-05-16-textarea-code-editor-design.md`
(historical reference only — its functional requirements remain authoritative;
its architectural choices, CodeJar + Prism over `contenteditable`, are stale).

**History.** A 35-commit implementation of the v1 design was rolled back in
commit `f14d352` after four classes of bug surfaced (copy collapses
multi-line selections, Enter sometimes needs two presses, autocomplete
fails silently at end-of-line, manual caret save/restore is unreliable).
All four are characteristic of `contenteditable` + tokenized DOM and
proved resistant to incremental fixes. The full post-mortem lives in
[`2026-05-17-textarea-editor-handoff.md`](./2026-05-17-textarea-editor-handoff.md).

## Goal

Upgrade three plain-text `<textarea>` fields in `l4d2web` into a
lightweight code editor with syntax highlighting and identifier-as-you-type
autocomplete. Motivating case: the blueprint config field, where users
edit Source-engine `server.cfg`-style content but have to remember
hundreds of L4D2 cvar names from memory.

## Architecture

CodeMirror 6 is the editing engine. Pre-built into a single IIFE bundle
that exposes a narrow façade (`window.__editor.mount(textarea, opts)`).
The bundle is produced by `esbuild` running locally and committed under
`static/vendor/`. No build step on prod; a `git pull` ships the
artifact verbatim. This matches the existing "vendor + pin + SHA-record"
pattern (currently used for none-yet, but planned for parity with the
v1 attempt's vendoring of CodeJar/Prism).

Why CodeMirror 6 over the v1 stack: the bug classes that killed v1 are
exactly what CodeMirror 6's editor surface has been hardened against
over years — copy/paste across token DOM, IME composition, selection
preservation across re-highlight, caret-between-nodes edge cases. We
trade a one-time build pipeline addition (Node + esbuild locally) for
the elimination of an entire bug class.

### Form-bridge pattern: submit-time copy

CodeMirror 6 owns the live document. The original textarea stays in
the DOM as the named form field (display:none) but acts as a
**submit-time courier**, not a live mirror. Two concrete bridges:

1. **HTML form POSTs** (`blueprint_detail.html`, script overlay):
   `editor.js` installs a capture-phase `submit` listener on the
   enclosing `<form>` that does
   `textarea.value = controller.getValue()` exactly once per submission.
2. **JSON-save fetch** (files-editor modal): `files-overlay.js` calls
   `controller.getValue()` at save time instead of reading
   `textarea.value`.

```
                        ┌──── HTML form POST ──────────────────┐
                        │  textarea.value (written at submit)  │
                        ▲                                      │
  ┌────────────────────┴──────────────────────────────────┐    │
  │  <textarea name="config" data-editor-language=        │    │
  │            "srccfg" hidden>…seed…</textarea>          │    │
  └──────────────────────────┬────────────────────────────┘    │
                             │ hidden, courier-only            │
              mounted next to│ ▲ on form `submit` (capture):   │
                             │ │   textarea.value =            │
                             │ │     view.state.doc.toString() │
                             ▼ │                               │
  ┌────────────────────────────┴──────────────────────┐        │
  │  <div class="editor-shell"> ◄── EditorView (cm6) ─┤        │
  │    cm-content (contenteditable, hardened by cm6)  │        │
  │  </div>                                           │        │
  └───────────────────────────────────────────────────┘        │
                             │ controller.getValue()           │
                             ▼ called at JSON-save time        │
                       fetch('/files/save', …) ────────────────┘
```

Why submit-time copy and not a per-keystroke live mirror: live mirroring
keeps two sources of truth (cm6 doc + textarea) in sync, fires
`input` events on the hidden textarea on every keystroke (a semantic
muddle — those events historically mean "the user typed", here they
also mean "cm6 dispatched"), and requires guard code to prevent
mirror writes from re-entering cm6. Submit-time copy collapses to a
single source of truth (cm6 owns the doc), with the textarea written
once per submission. Cost: 5 read sites + 3 write sites in
`files-overlay.js` switch from `textarea.value` to `controller.getValue()`
/ `controller.setContent()` — bounded, mechanical, single file.

### Subsystems

| Unit | Purpose | Depends on |
|---|---|---|
| `editor.js` | Un-bundled. Bootstraps cm6 on every `textarea[data-editor-language]`, hides the textarea, installs the submit-capture handler, exposes per-textarea controllers. | bundled cm6 + `tokens.css` |
| `editor.bundle.js` | Pre-built cm6 + extensions + custom srccfg grammar + light/dark themes, exposed as `window.__editor` (façade). | esbuild + npm deps |
| `editor.css` | Skins cm6 classes (`.cm-keyword`, `.cm-string`, gutter, selection, caret) against `tokens.css` CSS variables. | `tokens.css` |
| `srccfg-vocab.json` | Autocomplete corpus (~2196 cvars/cmds). | `build-vocab.py` reading `cvar_list` |
| `_editor_assets.html` | Jinja partial injecting nonce-tagged `<script>`/`<link>` tags. Loaded only on pages that mount an editor. | `g.csp_nonce` |

## Call sites

| Template | Line | Language | Save path |
|---|---|---|---|
| `l4d2web/l4d2web/templates/blueprint_detail.html` | 52 | `srccfg` | HTML form POST → `blueprint_routes.update_blueprint_form` |
| `l4d2web/l4d2web/templates/overlay_detail.html` | 25 | `bash` | HTML form POST → overlay script update route |
| `l4d2web/l4d2web/templates/overlay_detail.html` | 178 | `auto` (filename-derived; dropdown override) | `fetch('/files/save', …)` from `files-overlay.js` |

## Editor module contract

`esbuild` bundles `editor-entry.js` to a single IIFE that exposes
exactly one global, `window.__editor`:

```js
window.__editor = {
  mount(textarea, {language, vocab}) → controller,
  // controller exposes:
  //   getValue(): string          // live cm6 doc.toString()
  //   setContent(text): void      // replace doc, no input event fired
  //   setLanguage(name): void     // swap language Compartment contents
  //   destroy(): void             // tear down the EditorView
};
```

The narrowness is deliberate. cm6's full API surface is huge; only
this façade couples to the rest of the codebase. Future upgrades (cm7,
swap back to overlay, etc.) only need to preserve this contract.

`editor.js` (un-bundled, in `static/js/`) is the only consumer:

1. `document.querySelectorAll('textarea[data-editor-language]')`
2. For each: hide the textarea, mount the controller, register a single
   capture-phase `submit` handler on the enclosing `<form>` (if any)
   that calls `textarea.value = controller.getValue()`. Forms with
   multiple editors share one listener.
3. Files-editor modal: expose a named alias
   `window.__filesEditor` pointing at the controller mounted on the
   modal's textarea, so `files-overlay.js` can call `.getValue()` at
   save time and `.setContent(text)` on file-open without touching
   the textarea directly.
4. If `window.__editor` is undefined (bundle failed to load), leave
   the textarea visible — graceful no-JS fallback. The browser
   submits the raw textarea value exactly like today.

## Languages

| Language | Source | Notes |
|---|---|---|
| `srccfg` | `srccfg-mode.js` — `StreamLanguage.define(…)`, ~30 LOC | Tokens: comment `//…`, string `"…"`, number, keyword (`exec`, `alias`, `bind`), identifier. Linewise. Tied to `srccfg-vocab.json` for autocomplete. |
| `bash` | `@codemirror/legacy-modes/mode/shell` via `StreamLanguage.define()` | Official cm6 port of the CodeMirror 5 shell mode. Same loading mechanism as `srccfg`. |
| `auto` | Resolved on mount from filename input | `.cfg → srccfg`, `.sh → bash`, otherwise `plain`. Re-evaluated on filename input change while dropdown sits in "auto" state. |
| `plain` | No language extension | Editor still mounts so the language `<select>` remains usable. |

StreamLanguage chosen over Lezer: `.cfg` content is linewise, has no
nesting, no indentation rules, no folding semantics worth modeling.
StreamLanguage's character-stream API maps directly onto the regex
grammar we already designed for v1. Lezer's incremental + structure-aware
machinery has nothing here to act on; deferred as a future-work upgrade
only if richer features land.

## Autocomplete

cm6's `@codemirror/autocomplete` (`autocompletion()` extension) wired
to a `CompletionSource`:

1. On every keystroke, fetch the word fragment at the caret via
   `context.matchBefore(/[A-Za-z0-9_]{2,}/)`.
2. Return `{from, options}` where `options` is an array of
   `{label, info}` from case-insensitive prefix → substring match
   against the active language's vocab.
3. Sort: prefix matches first (shortest first), substring matches
   second; cap at 50; render top 8.

cm6 handles popup positioning, keybindings (↑/↓ navigate, Enter
accept, Esc dismiss), and rendering. We do **not** re-implement the
popup — the v1 attempt's hand-rolled popup was exactly the layer this
architecture eliminates.

`Tab` is bound to "accept" via a custom keymap entry; `Enter` likewise.
Both override the defaults (insert tab, insert newline) **only when
the popup is open** — cm6's `acceptCompletion` command returns false
when the popup is closed, falling back to the default key behavior.

## Vocabulary

`l4d2web/scripts/build-vocab.py` reads the existing repo-root file
`cvar_list` (~230 KB, 2196 entries, columnar dump from a live L4D2
server with the project's SourceMod plugins loaded). Parser:

- Skip the 2-line header (`cvar list` + divider).
- Split each row on `:` with limit 3 — descriptions sometimes contain
  `:` characters.
- Categorize: rows with `cmd` in column 2 → `commands`; others → `cvars`.
- Write `l4d2web/l4d2web/static/data/srccfg-vocab.json`:

  ```json
  {
    "version": 1,
    "generated_from": "cvar_list",
    "cvars":    [{"name": "sv_cheats", "desc": "Allow cheat cvars (0/1)"}, …],
    "commands": [{"name": "exec",      "desc": "Execute a .cfg file"},      …]
  }
  ```

Idempotent. Header comment records the source-file SHA + regeneration
command.

Lazy fetch in `editor.js` on first `srccfg` editor mount; cached on
`window.__srccfgVocab` so multiple editors on the same page share the
load.

No engine-internal trimming in v1. Prefix-match naturally surfaces
what users actually type; the file ends up ~160 KB JSON which is well
within budget. Trim pass available as future work if it ever matters.

## Theme

Two cm6 themes built with `EditorView.theme({...}, {dark: …})`,
authored under `editor-src/theme-{light,dark}.js`. Both keyed to CSS
custom properties:

```css
:root {
  --cm-bg: var(--bg-surface);
  --cm-fg: var(--fg-primary);
  --cm-keyword: var(--syntax-keyword);
  --cm-string:  var(--syntax-string);
  --cm-comment: var(--syntax-comment);
  --cm-number:  var(--syntax-number);
  --cm-selection: var(--bg-selection);
  /* … */
}
```

The site has no explicit light/dark toggle today; `tokens.css:38`
already uses `@media (prefers-color-scheme: dark)` to swap palette
variables OS-driven. The editor follows the same model: cm6's active
theme swapped via a `Compartment` driven by
`window.matchMedia('(prefers-color-scheme: dark)')` (with a `change`
listener for live OS-level swaps). `tokens.css` gains the syntax-color
variables that v1 added (and `f14d352` removed); we re-introduce them
inside both the default block and the `prefers-color-scheme: dark`
block, exposed to cm6 via `editor.css`.

## Build pipeline (new in this codebase)

The first JS build step in the repo. Lives in `l4d2web/scripts/`,
runs locally on demand. Outputs commit to `static/vendor/`. No CI
integration (the repo has no CI today); deploys are still
`git pull`-shaped.

```
l4d2web/scripts/
  build-editor.sh   # bash: cd into editor-src/; npm ci; npx esbuild …
  build-vocab.py    # python: parse ../../cvar_list → srccfg-vocab.json
  editor-src/
    package.json    # cm6 deps, pinned
    package-lock.json
    editor-entry.js # imports + façade
    srccfg-mode.js  # StreamLanguage.define({ token(stream) { … } })
    theme-light.js
    theme-dark.js
  README.md         # build instructions, dep list, SHA recording
```

`editor-src/node_modules/` is `.gitignore`d. Dev rebuild needs only
`node` + `npm`. Bundle outputs land at
`l4d2web/l4d2web/static/vendor/editor.bundle.{js,css}` with
`editor.bundle.sha256` for integrity.

## CSP & asset layout

CSP today is strict: `default-src 'self'; script-src 'self' 'nonce-…'`
(`l4d2web/l4d2web/app.py:101`). All editor assets self-hosted; all
`<script>` tags carry `nonce="{{ g.csp_nonce }}"`. No third-party
origins.

```
l4d2web/l4d2web/static/
  vendor/
    editor.bundle.js          # cm6 + extensions + srccfg-mode + themes, IIFE
    editor.bundle.css         # cm6 base styles (extracted by esbuild)
    editor.bundle.sha256
    README.md                 # versions, build command, integrity hashes
  css/
    editor.css                # CSS-variable bridge: tokens.css → cm6 classes
    tokens.css                # gains --syntax-* + --cm-* variables (light/dark)
  js/
    editor.js                 # un-bundled glue
  data/
    srccfg-vocab.json         # generated, committed
l4d2web/l4d2web/templates/
  _editor_assets.html         # nonce'd <link>/<script> tags
```

## Files touched

| File | Change |
|---|---|
| `l4d2web/l4d2web/templates/blueprint_detail.html` | Add `data-editor-language="srccfg"` to the `config` textarea (line 52); include `_editor_assets.html` partial |
| `l4d2web/l4d2web/templates/overlay_detail.html` | Add `data-editor-language="bash"` to script textarea (line 25); add `data-editor-language="auto"` + language `<select>` to files-editor textarea (line 178); include partial |
| `l4d2web/l4d2web/templates/_editor_assets.html` | **New** Jinja partial with nonce'd asset tags |
| `l4d2web/l4d2web/static/vendor/editor.bundle.{js,css}` | **New**, committed build artifacts |
| `l4d2web/l4d2web/static/vendor/editor.bundle.sha256` | **New**, integrity record |
| `l4d2web/l4d2web/static/vendor/README.md` | **New**, build command + dep versions + SHAs |
| `l4d2web/l4d2web/static/js/editor.js` | **New** ~100 LOC: mount loop + submit-capture handler + files-modal façade |
| `l4d2web/l4d2web/static/css/editor.css` | **New** CSS-variable bridge |
| `l4d2web/l4d2web/static/css/tokens.css` | Add `--syntax-*` and `--cm-*` light/dark variables |
| `l4d2web/l4d2web/static/data/srccfg-vocab.json` | **New**, generated, committed |
| `l4d2web/l4d2web/static/js/files-overlay.js` | 5 reads of `editorEls.contentBox.value` (lines 289, 464, 479, 494, 511) → `window.__filesEditor.getValue()`; 3 writes (lines 345, 378, 385) → `window.__filesEditor.setContent(…)`. Language dropdown calls `window.__filesEditor.setLanguage(…)`. |
| `l4d2web/scripts/build-editor.sh` | **New**, bundle build script |
| `l4d2web/scripts/build-vocab.py` | **New**, `cvar_list` → JSON parser |
| `l4d2web/scripts/editor-src/` | **New**: `package.json`, `package-lock.json`, `editor-entry.js`, `srccfg-mode.js`, `theme-{light,dark}.js`, `.gitignore` excluding `node_modules/` |
| `l4d2web/tests/test_blueprints.py`, `l4d2web/tests/test_script_overlay_routes.py` | Extend: assert editor markup in GET responses; assert form POST contract preserved |
| `l4d2web/tests/e2e/test_editor.py` | **New** Playwright: type→popup→Tab-accept→assert value; plus copy regression gate |
| `AGENTS.md` | Add `node + npm` as dev dependency for editor rebuilds |
| `cvar_list` | Untouched input; documented in `srccfg-vocab.json` header |

**Untouched by design:** `l4d2web/l4d2web/blueprint_routes.py`, all DB
code, all route code. The form-POST contract and the
`fetch('/files/save')` JSON contract are unchanged.

## Test strategy

Three layers, shallow to deep:

1. **Form-contract pytest** — verify the form POST shape is identical
   to today's: same field names, same accepted values, same redirect
   behavior. The textarea remains the named form field (just hidden);
   the submit-capture handler is what produces its value. Pre-write
   these before bundle work; they pin the contract so the obvious
   failure mode of submit-time copy (missing the submit event,
   handler order issue, form-without-`<form>`) surfaces immediately.
2. **HTML-assertion pytest** — verify GET on each detail page contains
   the expected `data-editor-language="…"` attribute and the editor
   asset block. Cheap, no browser.
3. **Playwright e2e (`test_editor.py`)** — the canonical gate from the
   handoff doc:
   - Login as `dev`/`devdevdev`.
   - Visit `/blueprints/1` (seeded by `scripts/dev-server.py`).
   - Type `sv_che` in the editor.
   - Assert the autocomplete popup appears with `sv_cheats`.
   - Press Tab.
   - Assert the hidden textarea's value, after dispatching a `submit`
     event on the form, contains `sv_cheats`.
   - Submit form; assert DB row reflects the value.
   - **Copy regression gate** (bug class 1 from v1): select multiple
     lines, dispatch a copy event, assert clipboard content has line
     breaks. cm6 handles this correctly out of the box; the test
     guards against future regressions.

## Verification

```bash
# 1. Bundle build (one-time per dep change)
cd l4d2web/scripts/editor-src && npm ci && cd ..
./build-editor.sh                         # produces static/vendor/editor.bundle.*
./build-vocab.py                          # produces static/data/srccfg-vocab.json

# 2. Local smoke
./scripts/dev-server.py                   # http://127.0.0.1:5051 (dev / devdevdev)
# Walk through: /blueprints/1, /overlays/1, /overlays/2 (files modal)

# 3. Fast suite
uv run pytest                             # excludes -m e2e

# 4. Browser suite (one-time `uv run playwright install chromium`;
#    Bash needs dangerouslyDisableSandbox: true because Chromium's
#    Mach-port IPC is sandbox-blocked)
uv run pytest -m e2e
```

## Open / Closed

- **Closed in v2:** line numbers, in-editor search, multi-cursor,
  bracket matching, theme switching beyond the global toggle,
  SourcePawn highlighting, bash buffer-context autocomplete,
  per-server live `cvarlist` capture, vocab trimming.
- **Open as additive future work:** line numbers (cm6 ships them as
  a one-line `import + extension`); search (cm6 built-in); multi-cursor
  (cm6 built-in); SourcePawn grammar; per-server cvar augmentation;
  Lezer-based srccfg grammar if structure-aware features ever land.

cm6 brings several closed-in-v2 features as ~5-LOC additions; that
is itself an argument for the chosen architecture, but explicitly not
shipped here. The v1 spec's stance on scope still holds: ship the
minimum surface, validate the architecture, accrete features later.

## Reusable patterns referenced

- `l4d2web/l4d2web/static/js/blueprint-overlay-picker.js` —
  progressive enhancement via DOM-manipulated form fields. This editor
  follows the same pattern: native form serialization, no submit
  interception except the synchronous textarea-value-copy step.
- `l4d2web/l4d2web/app.py:86` — `g.csp_nonce` accessor for template
  `<script>` tags.
- `l4d2web/l4d2web/static/js/files-overlay.js` — JSON-fetch save path.
  Continues to drive the save; reads/writes route through the editor
  façade instead of directly hitting `textarea.value`.
