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>
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 commitf14d352. 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:
- Copy collapses multi-line selections to one line.
- Enter sometimes needs two presses + cursor color shifts.
- 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). - Manual caret save/restore is required around any
updateCodecall — 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.jsl4d2web/l4d2web/static/js/srccfg-grammar.jsl4d2web/l4d2web/static/css/editor.cssl4d2web/l4d2web/static/vendor/{prism.js,prism.css,codejar.js,README.md}l4d2web/l4d2web/static/data/srccfg-vocab.jsonl4d2web/l4d2web/templates/_editor_assets.htmll4d2web/tests/e2e/test_editor.py- The
data-editor-languageattributes and partial includes onblueprint_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. Thelive_serverfixture inconftest.pyboots 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 newtest_editor.pyagainst whatever shape the next editor takes.pyproject.toml—playwright>=1.49.0+pytest-playwright>=0.6.0in[dependency-groups].dev,e2emarker registered,addopts = ["-m", "not e2e"]so the default fast suite excludes browser tests.AGENTS.md— documentsuv run playwright install chromiumfor 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.
-
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 viaSelection.toString()and doesn't reliably reconstruct newlines across that mix. Workarounds need acopyevent handler that overrides the clipboard payload from the editor's source text, not the DOM walk. -
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) andanchorOffsetindexing 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. -
Manual
Range.setStart(endContainer, endOffset - N)is unsafe. It assumes the trailing N characters live inendContainer. When the fragment is upstream of the caret's actual text node (case 2), the subtraction goes negative →IndexSizeError. We worked around it withSelection.modify('extend', 'backward', 'character')but the underlying fragility persists. -
CodeJar's
updateCode(text)does not preserve caret. It doeseditor.textContent = code; highlight(editor)and returns; the caret resets to start. Manualjar.save()+jar.restore(pos)is required around it. Even then,save()has a special case atcodejar.js:122-127that 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=1demo-srccfg— has Source-engine.cfgcontent ready to highlight - Script overlay
id=1demo-bash— has example bash for highlighting - Files overlay
id=2demo-files— hastest.cfgready to open in the files-editor modal - Server
id=1demo-serverlinked to the blueprint
Open questions for the brainstorming session
- Decision: Option 2 or Option 3. Don't skip this — it shapes everything downstream.
- If Option 2: where does the
npm install+esbuildstep live?l4d2web/scripts/build-editor.sh? AMakefiletarget? How is the bundle's provenance recorded (SHA256s, as we did with prism/codejar)? Does the bundle commit tostatic/vendor/or gets generated on demand? - 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. - 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?
- 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.
- 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.