left4me/docs/superpowers/specs/2026-05-17-textarea-editor-v2-design.md
mwiegand 778f98dedf
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>
2026-05-17 01:35:27 +02:00

20 KiB

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.

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:

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.jsStreamLanguage.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:

    {
      "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:

: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 .gitignored. 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

# 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:86g.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.