# Textarea code editor — handoff after rollback

> Follow-up artifact after a 35-commit attempt at a syntax-highlighting
> + autocomplete editor (CodeJar + Prism over `contenteditable`) was
> rolled back in commit `f14d352`. This is **not an implementation plan**
> — it's a brief for the next brainstorming session to pick a different
> architecture and produce a new spec. The "Don't restart this cycle"
> note below is load-bearing.

## Context

We shipped a 12-task implementation of an in-browser code editor that
upgraded three `<textarea>` fields in `l4d2web`:

- Blueprint config (Source-engine cvar syntax, with cvar autocomplete)
- Overlay bash script
- Files-editor modal (auto-detect language from filename + dropdown override)

The architecture: vanilla JS, no bundler, CSP-self-hosted. CodeJar
(~3 KB) provided the editor shell over a `contenteditable` element;
Prism (~25 KB, custom build of core + clike + bash) provided syntax
highlighting. A small custom Prism grammar covered Source-engine
`.cfg` syntax. Autocomplete was a ~150-line popup widget we wrote
ourselves, backed by a curated 30-cvar + 8-command JSON vocabulary.

Every code-review iteration caught real correctness bugs that we
addressed (capture-phase keydown so CodeJar's Tab handler didn't eat
the popup-accept; popup leak on destroy; async race in vocab loader;
try/catch on the Range manipulation; theme-aware syntax-token CSS
variables). Then a live inspection session surfaced four further
classes of bug that we couldn't address without more whack-a-mole:

1. **Copy collapses multi-line selections to one line.**
2. **Enter sometimes needs two presses + cursor color shifts.**
3. **Auto-completion fails silently at end-of-line** (the caret sits
   in a post-Prism-`<span>` text node, so the Range-API subtraction
   goes negative).
4. **Manual caret save/restore is required around any
   `updateCode` call** — and even with that, the caret can land
   "between" sibling nodes where browsers render it differently.

All four are characteristic of `contenteditable` + manual Range API
manipulation against tokenized DOM. The architecture was wrong for
the goal. Rolling back to the editor-free baseline was the right call;
each new fix was reinforcing a sunk-cost path.

## What's in the repo now

### Removed (in commit `f14d352`)

- `l4d2web/l4d2web/static/js/editor.js`
- `l4d2web/l4d2web/static/js/srccfg-grammar.js`
- `l4d2web/l4d2web/static/css/editor.css`
- `l4d2web/l4d2web/static/vendor/{prism.js,prism.css,codejar.js,README.md}`
- `l4d2web/l4d2web/static/data/srccfg-vocab.json`
- `l4d2web/l4d2web/templates/_editor_assets.html`
- `l4d2web/tests/e2e/test_editor.py`
- The `data-editor-language` attributes and partial includes on
  `blueprint_detail.html`, `overlay_detail.html`
- The files-overlay.js editor bridge (`setEditorContent`, dropdown
  listener, filename auto-redetect)
- The create-blueprint modal trim (Arguments + Config textareas restored)
- The four syntax-color tokens added to `tokens.css`
- Three form-contract tests in `test_blueprints.py` /
  `test_script_overlay_routes.py`

### Kept (informs the next attempt)

- `docs/superpowers/specs/2026-05-16-textarea-code-editor-design.md` —
  the original design spec. Still useful as the **functional**
  requirements (which textareas to upgrade, what autocomplete should
  do, what languages to support). The architectural choices in there
  (CodeJar, Prism) are stale; the requirements are not.
- `docs/superpowers/plans/2026-05-16-textarea-code-editor.md` — the
  12-task plan. Stale architecturally; useful as a reference for the
  testing strategy (form-contract assertions, Playwright e2e shape).
- `scripts/dev-server.py` — boots Flask locally with all the
  production-equivalent env vars (DATABASE_URL, LEFT4ME_ROOT,
  SESSION_COOKIE_SECURE=0, JOB_WORKER_ENABLED=false). Auto-seeds an
  admin user (`dev`/`devdevdev`) plus a blueprint, two overlays, and a
  server on first run so the next iteration can be inspected
  immediately at `/blueprints/1`, `/overlays/1`, `/overlays/2`, and
  `/servers/1`.
- `l4d2web/tests/e2e/{__init__.py,conftest.py,test_smoke.py}` —
  Playwright scaffolding. The `live_server` fixture in `conftest.py`
  boots the Flask app on an ephemeral port with a temp SQLite DB,
  seeds a user + blueprint, yields `{base_url, user_id, blueprint_id}`.
  Ready to receive a new `test_editor.py` against whatever shape the
  next editor takes.
- `pyproject.toml` — `playwright>=1.49.0` + `pytest-playwright>=0.6.0`
  in `[dependency-groups].dev`, `e2e` marker registered,
  `addopts = ["-m", "not e2e"]` so the default fast suite excludes
  browser tests.
- `AGENTS.md` — documents `uv run playwright install chromium` for
  first-time setup, plus the Claude Code sandbox + Chromium Mach-port
  workaround.

## The four bugs (so the next attempt can sanity-check itself)

These are the failure modes that surfaced. The next architecture
should either *avoid the class of bug entirely* or *have evidence of
having solved it in the chosen library*.

1. **Selection.toString() collapses across span topology.** Prism's
   tokenized output emits `<span>for</span>` `<span>vpk</span>`
   `<span>in</span>` … with newlines either inside spans or as bare
   sibling text nodes. The browser's copy implementation walks the
   selection via `Selection.toString()` and doesn't reliably
   reconstruct newlines across that mix. Workarounds need a `copy`
   event handler that overrides the clipboard payload from the
   editor's *source text*, not the DOM walk.

2. **Caret can land "between" sibling DOM nodes.** When the caret is
   at end-of-line or between two adjacent tokenized spans, the
   browser's selection has `anchorNode === <code>` (the parent
   element) and `anchorOffset` indexing into its children — NOT a
   text node. Most range-manipulation code assumes text-node
   containers and fails (silently or loudly) in this state. The
   visual cue is a different-color caret.

3. **Manual `Range.setStart(endContainer, endOffset - N)` is unsafe.**
   It assumes the trailing N characters live in `endContainer`. When
   the fragment is *upstream* of the caret's actual text node (case
   2), the subtraction goes negative → `IndexSizeError`. We worked
   around it with `Selection.modify('extend', 'backward', 'character')`
   but the underlying fragility persists.

4. **CodeJar's `updateCode(text)` does not preserve caret.** It does
   `editor.textContent = code; highlight(editor)` and returns; the
   caret resets to start. Manual `jar.save()` + `jar.restore(pos)` is
   required around it. Even then, `save()` has a special case at
   `codejar.js:122-127` that returns end-of-all-text if the selection
   sits in the editor element rather than a text node — interacting
   badly with case 2.

In aggregate, these are the bugs a *generic* contenteditable wrapper
inherits. A mature library (CodeMirror 6) has invested years in
handling them. A textarea-overlay approach avoids them by construction
because the editing surface is a real `<textarea>`.

## Decision pending

Two finalists for the next attempt:

| Concern | Option 2: CodeMirror 6 | Option 3: Textarea-overlay |
|---|---|---|
| Bundler aversion | adds a one-time `npm install` + bundle step | no bundler |
| Editing fluency (selections, IME, mobile, copy/paste) | strong (years of contenteditable hardening) | strong (native `<textarea>`) |
| IDE features (line numbers, search, multi-cursor) | strong, built-in or import-away | basic only; you'd build them |
| Autocomplete popup positioning | trivial (built-in `@codemirror/autocomplete`) | medium (mirror-div trick for caret pixel coords; well-known recipe, ~30 LOC) |
| Risk of recurring contenteditable bugs | low (handled) | none (no contenteditable) |
| Bundle weight | ~150 KB gzipped | ~10 KB Prism + ~80 LOC overlay JS |
| Source-cfg language | Lezer parser (more correct, more work) | Prism regex grammar (we already wrote one) |
| Long-term maintenance | high (heavy dep, but stable) | low (vanilla; font-metric edge cases) |

### Recommendation (advisory only)

**Option 2 (CodeMirror 6)** — the contenteditable bugs we just hit are
exactly what CodeMirror 6 was built to solve, and the "no bundler"
constraint we set originally cost us more in whack-a-mole than the
one-time build step would have. But Option 3 is also defensible and
carries zero contenteditable risk.

Don't pre-commit to either before the brainstorming session
re-examines the requirements; this is just the controller's lean
after living through the failure.

## Inputs for the next session

When you start brainstorming the next attempt, load these:

```
docs/superpowers/specs/2026-05-16-textarea-code-editor-design.md
    Original requirements (still valid)
docs/superpowers/plans/2026-05-16-textarea-code-editor.md
    Original plan (architecturally stale, useful for testing strategy)
docs/superpowers/specs/2026-05-17-textarea-editor-handoff.md
    This document
```

Useful runtime entry points:

```bash
# Local smoke (Flask + seeded demo content)
./scripts/dev-server.py
# Then visit http://127.0.0.1:5051/login as dev / devdevdev

# Fast test suite (excludes e2e)
uv run pytest

# E2E suite (needs `playwright install chromium` once; pass
# dangerouslyDisableSandbox: true on Claude Code's Bash tool because
# Chromium's Mach-port IPC is blocked by the sandbox)
uv run pytest -m e2e
```

The dev-server seeds (idempotent on `.tmp/dev-server` being clean):

- Blueprint `id=1` `demo-srccfg` — has Source-engine `.cfg` content
  ready to highlight
- Script overlay `id=1` `demo-bash` — has example bash for highlighting
- Files overlay `id=2` `demo-files` — has `test.cfg` ready to open in
  the files-editor modal
- Server `id=1` `demo-server` linked to the blueprint

## Open questions for the brainstorming session

1. **Decision: Option 2 or Option 3.** Don't skip this — it shapes
   everything downstream.
2. If **Option 2**: where does the `npm install` + `esbuild` step
   live? `l4d2web/scripts/build-editor.sh`? A `Makefile` target?
   How is the bundle's provenance recorded (SHA256s, as we did with
   prism/codejar)? Does the bundle commit to `static/vendor/` or
   gets generated on demand?
3. If **Option 2**: which CodeMirror packages exactly? Minimum useful
   set is probably `@codemirror/state`, `@codemirror/view`,
   `@codemirror/commands`, `@codemirror/lang-bash`, `@codemirror/autocomplete`,
   plus a custom Source-cfg StreamLanguage or Lezer grammar.
4. If **Option 3**: how does the autocomplete popup re-position on
   window scroll / page resize? Cross-browser font-metric quirks
   (Safari vs Chrome vs Firefox) — are we OK testing only Chromium?
5. **Form-contract testing**: the form-POST contract for the blueprint
   config / overlay script doesn't change regardless of which option
   we pick. Pre-write the form-contract tests against the textarea's
   eventual hidden/upgraded state so we know the contract holds early.
6. **Playwright e2e shape**: the harness is ready. The test that
   types `sv_che`, asserts the popup, accepts via Tab, asserts the
   textarea value — should be re-usable verbatim against either
   approach. Use it as the gate.

## Don't restart this cycle

The temptation will be to try a third contenteditable variant ("maybe
if we just add a different library on top of contenteditable…").
**Resist.** The bug classes we hit are characteristic of
contenteditable + tokenized DOM, not specific to CodeJar. A new
contenteditable wrapper that hasn't been hardened against years of
edge cases will reproduce the same bugs.

The two options that survive this constraint:

- A library that *has* been hardened (CodeMirror 6).
- An approach that *doesn't use* contenteditable at all (textarea-overlay).

Both are real options. Pick one.
