left4me/docs/superpowers/specs/2026-05-17-textarea-editor-v2-design.md
mwiegand 43d4104cef
fix(spec): use legacy-modes/shell for bash language
@codemirror/lang-bash is not an official package. cm6's official path
to bash highlighting is @codemirror/legacy-modes/mode/shell wrapped in
StreamLanguage.define(), matching the same mechanism we use for the
custom srccfg mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:36:50 +02:00

396 lines
20 KiB
Markdown

# 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`.