left4me/docs/superpowers/specs/2026-05-17-textarea-editor-handoff.md
mwiegand db5b2810a9
spec(textarea-editor): handoff after contenteditable rollback
Briefs the next brainstorming session with: what we built, the four
contenteditable failure modes that made it unshippable, what's still
in the repo (Playwright harness, dev-server, original spec/plan as
historical reference), the decision pending (CodeMirror 6 vs
textarea-overlay), inputs to load, and an explicit "don't restart
this cycle" caveat against trying a third contenteditable variant.

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

12 KiB

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.tomlplaywright>=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:

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