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:
parent
f14d352657
commit
db5b2810a9
1 changed files with 245 additions and 0 deletions
245
docs/superpowers/specs/2026-05-17-textarea-editor-handoff.md
Normal file
245
docs/superpowers/specs/2026-05-17-textarea-editor-handoff.md
Normal 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.
|
||||
Loading…
Reference in a new issue