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>
This commit is contained in:
mwiegand 2026-05-17 00:57:33 +02:00
parent f14d352657
commit db5b2810a9
No known key found for this signature in database

View file

@ -0,0 +1,245 @@
# 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.