spec(textarea-editor-v2): commit CodeMirror 6 design
Approved 2026-05-17. Supersedes 2026-05-16-textarea-code-editor-design.md. Architecture: CodeMirror 6 (bundled via esbuild, committed to static/vendor/). Form-bridge: submit-time copy (cm6 owns the doc; capture-phase submit handler writes textarea.value once; JSON-save path calls controller.getValue()). srccfg grammar via StreamLanguage; bash via @codemirror/lang-bash; autocomplete via @codemirror/autocomplete. Vocab generated from the existing repo-root ./cvar_list (2196 entries). Theme follows the site's prefers-color-scheme model. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
db5b2810a9
commit
778f98dedf
1 changed files with 396 additions and 0 deletions
396
docs/superpowers/specs/2026-05-17-textarea-editor-v2-design.md
Normal file
396
docs/superpowers/specs/2026-05-17-textarea-editor-v2-design.md
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
# 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/lang-bash` | Stock package. |
|
||||
| `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`.
|
||||
Loading…
Reference in a new issue