Commit graph

477 commits

Author SHA1 Message Date
mwiegand
4337002bd0
docs(files): rewrite plan for files-overlay.js (3 phases, 12 commits)
Three-phase plan to consolidate files-overlay.js's mixed event-binding
patterns and complete the URL-addressable modals migration:

  Phase A (4 commits): split the 35 KB IIFE into 4 focused modules
  under static/js/files-overlay/ — core, editor, dialogs, uploads —
  with consistent document-level delegation. Behavior unchanged.

  Phase B (6 commits): migrate the two remaining inline-dialog flows
  (create-new-file, binary-replace) to URL-addressable modals via a
  new /files/new route and a binary-mode branch in the edit route +
  template. Delete the legacy <dialog id="files-editor-modal"> from
  overlay_detail.html. editor.js becomes single-purpose (~200 lines).

  Phase C (2 commits): extract shared path/editability helper for
  routes/files_routes.py; delete the now-unused /files/content JSON
  endpoint; consolidate save/replace rename duplication.

Each commit is independently verifiable + revertable. Natural pause
points at the end of each phase. Plan is the handoff artifact for
cross-session execution.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:49:02 +02:00
mwiegand
c51089df1b
refactor(modals): consolidate modal.js + modal-router.js as inline/routed
Two modal pipelines coexisted after the URL-addressable pilot — modal.js
(inline, ~30 lines) and modal-router.js (routed, ~150 lines) — operating
on different attribute namespaces and exposing different APIs. Future
modal authors had two systems to learn with no naming convention to
help them pick the right one for a given use case.

Consolidates both into static/js/modals.js with two clearly-named
pipelines and a single window.modals.* API:

  Inline modal — content pre-rendered in the page.
    Hooks:  data-inline-modal-open="<dialog-id>"
            data-inline-modal-close
    API:    window.modals.openInline(idOrEl)
            window.modals.closeInline(idOrEl)
    Use:    confirmations, transient prompts, in-page forms without
            URL value.

  Routed modal — content fetched from a URL, ?modal=<path> in URL,
            with history + share-link + refresh-survival.
    Hooks:  <a data-routed-modal href="<path>">
            data-routed-modal-dismiss
    API:    window.modals.openRouted(path)
            window.modals.closeRouted()
    Use:    content with standalone-page meaning.

Single document-level click delegation handles all four attribute
hooks; one DOMContentLoaded handler binds dialog 'close' / 'cancel' /
backdrop on the routed slot; shared popstate and htmx:responseError
listeners. Behaviour unchanged — pure rename + colocation.

Renamed across 11 templates and files-overlay.js. Old data-modal-*
attributes and window.openModal/closeModal globals are gone — clean
break (no back-compat shims). AGENTS.md "Modals: inline vs routed"
section documents the decision guide for new modals.

Verified: 573 backend tests pass. 5/5 Chromium smoke checks pass
(inline open/close, Esc, backdrop, routed open+save, routed Esc).
Console clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:31:38 +02:00
mwiegand
74fd906cf4
docs(modals): codify URL-addressable modal template convention
Architectural problem flagged after the pilot: "the template renders both
as a standalone page AND as a modal fragment" contract is non-obvious for
future template authors. Task 2 originally used <dialog>, Task 8.5 had to
undo that because nested <dialog> collapses to 2px. The convention is now
in two places:

1. AGENTS.md gains a "URL-addressable modal templates" section under
   Non-Negotiable Constraints listing: outer element must be <div>, close
   buttons use data-modal-dismiss, form actions need #modal-content-scoped
   document delegation, modal chrome CSS is owned by the outer slot.
2. _modal_partial.html (the file template authors will most likely open
   when wondering "what's this layout?") carries a Jinja comment header
   summarising the rule + linking to AGENTS.md for the full convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:09:05 +02:00
mwiegand
712ccc9861
docs(modals): plan errata — 3 verbatim-code defects + 3 inserted tasks
The URL-addressable modals plan shipped in 14 commits. Three places
where the plan's verbatim code didn't survive contact with the codebase
(has_request_context guard, LEFT4ME_ROOT-aware fixture, save-handler
direct-bind) are now documented at the top of the plan, with commit
references for the fixes. Also notes the inserted tasks 8.5/8.5b/9b
and the Task 6 design refinement (close-event single state sink) so a
future re-executor sees the actual shipped pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:03:10 +02:00
mwiegand
55c7856eb1
fix(modals): drop dangling aria-labelledby + rename inner id
Two small follow-ups flagged during code review of Tasks 4 and 9:

1. <dialog id="modal-container" aria-labelledby="modal-content-title">
   referenced an id that never existed. Removed the attribute; the
   inner modal content provides its own aria-labelledby on the heading,
   and screen readers traverse dialog content reasonably without an
   outer label.
2. The new editor template's outer <div> shared id="files-editor-modal"
   with the legacy inline <dialog> in overlay_detail.html — duplicate
   id when the modal is open, W3C-invalid (though functionally inert).
   Renamed the new div to id="files-editor-fragment" and broadened
   editor.js's closest() selector to match both, so auto-language
   detection works for both the legacy and the new modal pipelines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:03:09 +02:00
mwiegand
33a2e529f6
fix(files): support rename-on-save in URL-addressable modal
Task 9's new save delegation read only the editor content, not the
filename input — so typing a new filename and clicking Save silently
discarded the rename and wrote to the original path. Matches the
legacy save handler's payload.new_path contract: if the user edited
the filename, compose new_path = parent/filename and send it. 409
conflict (destination exists) shows an alert and keeps the modal
open so the user can adjust.

Also exposes rawText in fetchJson return so plain-text server error
messages (e.g. "destination already exists") reach the alert call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 13:29:46 +02:00
mwiegand
64cf203890
feat(files): file-row click opens editor via URL-addressable modal
files-overlay.js no longer fetches /files/content JSON and populates
the inline <dialog>; it calls window.openModal(<edit-url>) which the
modal-router handles end-to-end. Binary files retain the old inline
dialog path (binary replace deferred from pilot scope).

Added document-level event delegation for .files-editor-save and
.files-editor-delete inside #modal-content so save/delete work on the
server-rendered editor DOM (reads data-rel-path from the textarea;
calls window.__filesEditor.getValue() for content; calls closeModal()
on success). The inline dialog's own save/delete handlers are untouched
and continue to serve the create-new-file and binary-replace flows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 13:13:38 +02:00
mwiegand
7829d1ca95
fix(modals): drop double-card chrome from inner editor div
The outer <dialog id="modal-container" class="modal modal-wide"> already
provides border, background, max-width, and padding. After Task 8.5
broadened the CSS to also match div.modal, the inner div was painting
its own card chrome inside the outer one — card-in-a-card visual.

Strip class="modal modal-wide" and role="document" from the inner div.
Standalone-mode renders the editor as flat content under <main> (the
"this URL is also a real page" promise; full-page = full-page, not a
modal-over-nothing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 13:07:28 +02:00
mwiegand
f6b8ecfd5d
fix(modals): nested-dialog rendering, CM6 destroy on close, mount idempotency
Three coupled lifecycle bugs surfaced during Task 5-8 reviews:

1. overlay_file_editor.html emitted a <dialog open> that nested inside
   the outer <dialog id="modal-container">, collapsing the modal to
   2px tall. Replaced with <div role="document" aria-labelledby="…">
   so a11y semantics survive and the layout actually renders.
2. modal-router.js's close-event handler now tears down CM6 controllers
   via controller.destroy() and clears #modal-content innerHTML, fixing
   a real leak (each open/close cycle was orphaning an EditorView and
   a matchMedia "change" listener on window).
3. mountOne in editor.js now short-circuits if the textarea already has
   a controller, defending against future double-mount paths.

CSS: added div.modal and div.modal.modal-wide selectors alongside the
existing dialog.modal ones so the editor <div> gets correct styling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:58:41 +02:00
mwiegand
f426970d4c
feat(editor): re-init CM6 on htmx:afterSwap into #modal-content
editor.js exposes initEditors(root) and listens for htmx:afterSwap so
editor textareas that arrive via modal swap get CM6 mounted. The
DOMContentLoaded path remains for first-paint mounting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:43:12 +02:00
mwiegand
afd2ed1c3c
feat(modals): DOMContentLoaded bootstrap reopens modal from ?modal= URL
Refresh and share-link flows both work — the modal-state URL is the
canonical shareable artifact for "this overlay with this file open."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:36:04 +02:00
mwiegand
6e66375233
feat(modals): close, popstate, dismiss, Esc, backdrop, response-error
Centralizes state cleanup on the dialog's native 'close' event: every
close source (Esc cancel event, backdrop click, [data-modal-dismiss],
browser back, htmx:responseError on the modal fetch, or programmatic
closeModal()) just calls dialog.close() and the single 'close' listener
clears ?modal= from the URL and resets currentModalPath. This avoids
the trap where legacy modal.js's backdrop close didn't sync our URL,
and the trap where a 4xx response opened an empty modal.

window.closeModal exposed for callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:28:36 +02:00
mwiegand
3de68b7539
docs(agents): forbid system paths in dev; point to scripts/dev-server.py
Production paths (/var/lib/left4me, /usr/local/...) exist only on Linux
deploy hosts. Local dev must use scripts/dev-server.py which sets
LEFT4ME_ROOT=./.tmp/dev-server and seeds demo content. Running plain
"flask run" leaves LEFT4ME_ROOT unset and the app falls back to the
production path, surfacing as 404s on what looks like code bugs.

Adds symptom-to-cause guidance so future agents diagnose "route 404 in
dev" as a config issue, not a code defect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:20:45 +02:00
mwiegand
bc8edbcd50
feat(modals): click intercept + openModal + fetchAndShow
a[data-modal] clicks push ?modal=<path> to URL and trigger htmx.ajax
into #modal-content with the HX-Modal header. window.openModal exposed
for non-<a> trigger sites (files-overlay row clicks). Race guard via
currentModalPath token. Close/popstate/bootstrap follow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:00:28 +02:00
mwiegand
8df130a607
feat(modals): persistent modal slot + router script stub in base.html
Adds <dialog id="modal-container"> with #modal-content slot at body
scope. Script stub created so the include doesn't 404; logic follows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:51:07 +02:00
mwiegand
60e79683fc
feat(modals): GET /overlays/<id>/files/edit route
Server-renders the file editor as a real page. With HX-Modal:1 returns
a layoutless fragment for modal embedding; without it returns the full
standalone page. Mirrors overlay_file_content's path/editability checks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:43:18 +02:00
mwiegand
a26b4cc34e
feat(modals): editor template that extends base_layout
Lifts the file editor markup out of overlay_detail.html into its own
template with server-side filename, content, byte count, and download
URL pre-filled. Uses {% extends base_layout %} so the same template
renders as either a full page or a layoutless modal fragment.

Binary replace UI deferred — pilot scope is editable text files only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:34:31 +02:00
mwiegand
82c3f041ce
feat(modals): layout context processor for HX-Modal header
Switches the Jinja base layout to _modal_partial.html (yield-only) when
the HX-Modal:1 request header is set, otherwise base.html. Foundation
for URL-addressable modals (spec 2026-05-17-url-addressable-modals).

Guards with has_request_context() so the processor is safe when
render_template_string is called from app_context() without a request
(e.g. test_timeago_filter_registered_on_app).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:27:25 +02:00
mwiegand
d05d00449f
docs(modals): implementation plan for URL-addressable modals pilot
10-task TDD plan: context processor + partial → editor template →
GET /files/edit route → modal slot + script stub → modal-router.js
(click+fetch+show → close+popstate+dismiss → bootstrap) → CM6 re-init
→ files-overlay.js wiring → remove inline dialog + Chromium matrix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:09:59 +02:00
mwiegand
fcab4b0b72
docs(modals): URL-addressable modals design (pilot: file editor)
Spec for the swift3-style ?modal=<path> pattern: same route renders full page
or layoutless fragment based on an HX-Modal header, ~50-line JS module owns
URL+history, HTMX owns fetch+swap, native <dialog> owns show/hide. Pilot
migrates the file editor's open/render flow only — save flow stays AJAX.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:03:46 +02:00
mwiegand
2942467cfd
feat(files-overlay): filename click opens editor, actions align next to row
Replaces the dedicated edit button with a click target on the filename
itself (download stays as a separate ⬇ action). Drops margin-left:auto on
.files-row-actions so action buttons sit immediately after the row's name
instead of at the far right.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 10:40:54 +02:00
mwiegand
54842f71c6
fix(editor-v2): fix cm6 to rows-derived height, eliminate layout shift
CLS verified zero (0.00000) on /blueprints/1 and /overlays/1 via
PerformanceObserver({type: 'layout-shift', buffered: true}) on a
real browser session — previously CLS=0.00859 from a 253 px shift
when cm6 mounted into a display:none slot.

Mechanism:
- editor-entry.js: mount() accepts `rows`. When provided, prepends
  an EditorView.theme that pins
    .cm-editor { height: calc(rows * 1.84rem + 1.125rem) }
  and sets .cm-scroller overflow:auto. cm6 renders at a fixed,
  predictable height; long content scrolls internally (same UX the
  raw <textarea rows="N"> used to give).
- editor.js: reads textarea.rows attribute and passes it to mount().
- editor.css: new .editor-mount wrapper uses the same calc on
  min-height keyed off an inline --editor-rows CSS custom property,
  so the slot is pre-reserved BEFORE cm6 mounts. Wrapper and cm6
  match exactly (browser-measured 254 / 254 px for rows=8 and
  607 / 607 px for rows=20).
- Templates: each editor textarea wrapped in
  <div class="editor-mount" style="--editor-rows: N">. Single source
  of truth on N (only the rows attribute + the inline custom prop
  vary per call site).

Per-row metric 1.84 rem derived empirically: 253 px for rows=8 minus
1.125 rem chrome = 235 px content, ÷ 8 ≈ 29.4 px = 1.84 rem.

Fast suite + e2e suite still green (3 + 2 pass, 0 fail).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 10:27:28 +02:00
mwiegand
2f1a1ef284
Revert "fix(editor-v2): reserve editor slot to stop layout shift on mount"
This reverts commit b915f2e766.
2026-05-17 02:34:24 +02:00
mwiegand
b915f2e766
fix(editor-v2): reserve editor slot to stop layout shift on mount
The previous flicker fix hid the textarea via CSS but display: none
removes it from layout entirely — so the page rendered with zero
height where the editor would go, then cm6 mounted and pushed the
surrounding form down by its full height (CLS).

Wrap each editor textarea in <div class="editor-mount" style="min-height: …rem">
so the slot is reserved before cm6 mounts. The wrapper is a flex
column with cm6 as flex: 1 so cm6 fills the reserved space rather
than collapsing to content-height with a gap below (the seeded
blueprint has 2 chars of content; without flex the editor would
shrink to one line).

Min-heights calibrated to rows × ~1.25rem + ~1.5rem chrome:
- config (rows=8)  → 12rem
- files (rows=14)  → 19rem
- script (rows=20) → 27rem

.cm-editor's own min-height: 8em rule removed — the wrapper is the
floor now, and the inner cm6 stretches to fill via flex.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:29:43 +02:00
mwiegand
fd0d96b349
fix(editor-v2): eliminate first-paint flicker
Three changes that together stop the page from briefly painting the
raw textareas before cm6 takes over:

1. base.html gains a {% block extra_head %}{% endblock %} hook.
2. blueprint_detail.html and overlay_detail.html include
   _editor_assets.html via that extra_head block instead of inside
   {% block content %}. Editor CSS now loads from <head>, so the
   textarea pre-hide rule (added below) applies before first paint;
   the defer'd scripts also download in parallel with HTML parse,
   which is the better default anyway.
3. editor.css adds
      textarea[data-editor-language] { display: none; }
   so opt-in textareas are hidden from the very first paint.

editor.js + _editor_assets.html cover the three paths the pre-hide
must not break:
- bundle didn't load: top-of-IIFE bails early and un-hides every
  matching textarea via style.display = "revert".
- per-textarea mount throws: init()'s catch un-hides that specific
  textarea so the form stays usable.
- JS disabled entirely: _editor_assets.html ships a <noscript>
  <style> override that un-hides via display: revert.

Fast suite + e2e suite both still green (676 + 3 pass, 0 fail).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:25:52 +02:00
mwiegand
704e4cdfd1
docs(editor-v2): AGENTS.md editor bundle rebuild section
Adds an 'Editor bundle (CodeMirror 6)' section after the e2e tests
section describing:
- where the source lives (l4d2web/scripts/editor-src/)
- how to rebuild (./l4d2web/scripts/build-editor.sh)
- the NPM_CACHE workaround for the root-owned ~/.npm cache files
- the vocab regeneration command (./l4d2web/scripts/build-vocab.py)
- pointers to the design + plan docs

Verified end-to-end:
- 676 fast tests passing (no regressions from the wiring)
- 3 e2e tests passing (smoke + 2 editor tests)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:17:38 +02:00
mwiegand
19bc0afaa9
test(editor-v2): Playwright e2e + Tab→acceptCompletion fix
Two e2e tests:
- test_blueprint_autocomplete_accept_writes_into_hidden_textarea:
  loads /blueprints/1, types 'sv_che', asserts the cm6 autocomplete
  popup shows 'sv_cheats', presses Tab to accept, fires a synthetic
  submit on the form, and reads the hidden textarea value back.
  Exercises both the autocomplete extension and the submit-time copy
  bridge in editor.js end-to-end.
- test_copy_preserves_newlines_across_lines: regression gate for
  bug class 1 from v1 (Prism+contenteditable collapsed multi-line
  selections). cm6 preserves linebreaks in its doc by construction;
  we verify via the per-textarea controller's getValue().

editor-entry.js: discovered during the e2e debug that cm6's default
completionKeymap does NOT bind Tab. Added an explicit
`{ key: "Tab", run: acceptCompletion }` ahead of the rest of the
keymap stack so Tab accepts when the popup is open and falls through
to indentWithTab otherwise. Bundle rebuilt + SHA refreshed.

Tests also surfaced a 200ms popup-settle timing race: the popup is
*visible* on the same tick acceptCompletion runs against null
selectedCompletion. A page.wait_for_timeout(200) before pressing
the accept key bridges the gap reliably in CI.

Chromium runs fine in Claude Code's default sandbox — the stale note
in the handoff doc about Mach-port IPC sandbox-blocking is no longer
accurate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:15:51 +02:00
mwiegand
42bdc6ad98
feat(editor-v2): files-overlay reads/writes via window.__filesEditor
Routes 8 call sites through the cm6 controller alias:
- 5 reads (byte-count, save POST, dirty checks at 306, 481, 496, 511,
  528) → getEditorValue() helper, falling back to
  editorEls.contentBox.value if window.__filesEditor isn't mounted
  (no-JS / pre-mount path).
- 3 writes (clear, "Loading…" placeholder, fetched body content at
  362, 395, 402) → setEditorValue() helper with the same fallback.

The two helpers live inline next to editorEls so the rest of the
module's call sites stay close to existing style.

Known regressions (out of scope for v2, candidate follow-ups):
- Byte-count badge updates only on file-open / setContent calls, not
  live on every keystroke. Needs a controller.onChange(cb) hook.
- Ctrl+S inside cm6 doesn't trigger the modal Save. cm6 owns the
  keymap in its editing surface; users can still click the Save
  button. Adding a custom cm6 keymap entry would restore the
  shortcut.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:08:42 +02:00
mwiegand
59446bc105
feat(editor-v2): wire data-editor-language attrs into three textareas
Templates:
- blueprint_detail.html:52 — config textarea gets
  data-editor-language="srccfg".
- overlay_detail.html:25 — script textarea gets
  data-editor-language="bash".
- overlay_detail.html files-modal — content textarea gets
  data-editor-language="auto"; new <select data-editor-language-select>
  (auto / srccfg / bash / plain); filename input gets
  data-editor-filename.
- Both templates {% include "_editor_assets.html" %} before
  {% endblock %}.

Tests (TDD red-green):
- test_blueprint_get_includes_editor_markup pins srccfg + bundle + glue
  in blueprint detail GET.
- test_script_overlay_detail_carries_editor_markup pins bash + bundle
  + glue in script overlay GET.
- Files-modal markup verified end-to-end in Task 14 Playwright (its
  pytest fixtures are heavyweight; not worth duplicating for a static
  markup assertion).

Fast suite stays at 564 passed (no regressions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:06:58 +02:00
mwiegand
9ca0e789f4
test(editor-v2): pin form-POST round-trip for blueprint config
New test_blueprint_config_form_post_round_trip — POSTs a multi-line
config, GETs the page, asserts each line re-renders inside the
textarea. Pins the round-trip the v2 editor's submit-time copy
handler must preserve before any template wiring lands.

Skipped a corresponding test_overlay_script_form_post_contract test
— the existing test_admin_creates_system_wide_script_overlay at
test_script_overlay_routes.py:~270 already asserts
overlay.script == "echo admin" after a POST /overlays/<id>/script,
which is the same form-contract pin. YAGNI; no need to duplicate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:02:47 +02:00
mwiegand
b1a6290c8c
feat(editor-v2): _editor_assets.html Jinja partial
Five-line partial included on every page that mounts an editor.
Two <link> stylesheets (vendor + glue) and two nonce'd <script>
tags (bundle + glue). The `defer` attribute preserves document
order, so editor.bundle.js (which assigns window.__editor)
executes before editor.js (which reads it).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:00:29 +02:00
mwiegand
e4f863415e
feat(editor-v2): editor.js glue (mount, submit-capture, files alias)
Un-bundled progressive-enhancement glue:
- DOMContentLoaded → mount cm6 on every textarea[data-editor-language].
- Each <form> gets one capture-phase submit handler that copies every
  contained editor's getValue() into its textarea.value before the
  browser serializes the form (submit-time copy bridge).
- The textarea with class files-editor-content (the files-modal
  textarea) exposes its controller as window.__filesEditor for
  files-overlay.js's getValue / setContent / setLanguage calls.
- 'auto' language resolves from the modal's filename input
  ([data-editor-filename]); a language [data-editor-language-select]
  dropdown lets the user override.
- Vocab fetched lazily on the first srccfg mount; cached for the page.

Falls through silently if window.__editor isn't defined (bundle
failed to load), keeping the raw textarea visible — no-JS fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:00:17 +02:00
mwiegand
921168722b
feat(editor-v2): tokens.css syntax vars + editor.css shell
tokens.css gains:
- --syntax-{keyword,string,comment,number}: source-of-truth syntax
  token colors, overridden in the prefers-color-scheme: dark block.
- --cm-{bg,fg,keyword,string,comment,number,selection}: bridge
  variables the cm6 themes (themes.js) reference. --cm-bg / --cm-fg
  route through the existing --color-surface / --color-text palette
  so they pick up dark-mode automatically.

editor.css scopes the cm6 shell (.cm-editor) to match the app's
existing --line / --radius-s / --color-focus tokens. Token colors
themselves come from cm6 themes, not this stylesheet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:59:41 +02:00
mwiegand
6af2e41fd8
feat(editor-v2): build script + first bundle output
build-editor.sh runs npm install + esbuild from editor-src/, produces:
- editor.bundle.js  324.6 KB minified IIFE, sets window.__editor.mount
- editor.bundle.css 0 B placeholder (cm6 injects styles at runtime
  via StyleModule; future extensions that need real CSS can drop into
  the same file without a template change)
- editor.bundle.sha256 integrity hashes

The script uses $TMPDIR/npm-cache (override via NPM_CACHE env var)
to work around root-owned files in the default ~/.npm cache from
older npm versions (the env's `npm ci` rejected the default cache).

vendor/README.md documents the rebuild command, the cache override,
and the integrity-record convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:58:46 +02:00
mwiegand
bfc8b82c00
feat(editor-v2): editor-entry façade wiring all extensions
Replaces the Task 1 stub. Builds an EditorView with:
- history, line numbers, active-line highlight, bracket matching,
  close brackets, indent-on-input
- default + custom HighlightStyle
- light/dark theme via matchMedia-driven Compartment with a
  prefers-color-scheme change listener
- language via Compartment (swappable for the files-modal dropdown)
- autocomplete via Compartment (only if vocab is provided)
- keymap stack: closeBrackets, default, history, completion, indentWithTab

Mounts the EditorView immediately before the textarea, hides the
textarea. Exposes window.__editor.mount(textarea, opts) returning a
controller with getValue / setContent / setLanguage / destroy.

bash language comes via @codemirror/legacy-modes/mode/shell wrapped
in StreamLanguage.define — same mechanism as srccfg.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:57:23 +02:00
mwiegand
3440bbc131
feat(editor-v2): autocomplete completion source
CompletionSource over the srccfg-vocab.json shape. Word fragment
matched via /[A-Za-z0-9_]{2,}/ at the caret; ranking is
prefix-match-first (shorter prefixes preferred) then substring;
cap 50 candidates, top 8 rendered. Each option carries the kind
('cvar'/'command') as cm6's autocomplete `type` so the popup
shows the appropriate icon.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:56:45 +02:00
mwiegand
5289ae307f
feat(editor-v2): light + dark themes + syntax highlight style
themes.js exports four extensions:
- editorLightTheme / editorDarkTheme: EditorView.theme() variants
  keyed to the --cm-* CSS variables defined in tokens.css (light) and
  its prefers-color-scheme: dark block.
- editorHighlightStyle: HighlightStyle bound to Lezer tags
  (comment, string, number, keyword, variableName).
- editorHighlighting: syntaxHighlighting(editorHighlightStyle) ready
  to drop into the EditorState extensions array.

@lezer/highlight comes in transitively via @codemirror/language;
no new package.json dependency needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:56:26 +02:00
mwiegand
9226963516
feat(editor-v2): srccfg StreamLanguage mode
~30 LOC StreamLanguage definition for Source-engine .cfg syntax.
Tokens: line comment (//…), string, number, keyword (exec/alias/bind/
unbindall/wait), identifier. Linewise, no nesting — matches the
shape we authored as a Prism regex grammar in the v1 attempt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:55:59 +02:00
mwiegand
7497cf5416
feat(editor-v2): vocab generator + cvar_list-derived JSON
build-vocab.py parses ./cvar_list (live L4D2 cvarlist dump, 2196 entries)
into static/data/srccfg-vocab.json — 1523 cvars + 671 commands.
Idempotent. Records the source-file SHA256 in the JSON header so
regenerations are auditable.

cvar_list is committed as a tracked data file so the generation is
reproducible from the repo alone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:55:33 +02:00
mwiegand
ce20c1abff
scaffold(editor-v2): pin cm6 deps + editor-src skeleton
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 01:54:06 +02:00
mwiegand
ebf6d2ebc6
plan(textarea-editor-v2): bite-sized TDD implementation plan
15 tasks covering: editor-src scaffold, vocab generator, srccfg
StreamLanguage mode, light/dark themes, autocomplete source, editor-entry
façade, esbuild build script + first bundle, tokens.css + editor.css,
editor.js glue (mount + submit-capture + __filesEditor alias),
_editor_assets.html partial, form-contract pytest pre-wiring gate,
template wiring with GET-asserts-markup TDD, files-overlay.js bridge
swap, Playwright e2e (autocomplete-accept + copy regression), docs +
final smoke.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:41:26 +02:00
mwiegand
43d4104cef
fix(spec): use legacy-modes/shell for bash language
@codemirror/lang-bash is not an official package. cm6's official path
to bash highlighting is @codemirror/legacy-modes/mode/shell wrapped in
StreamLanguage.define(), matching the same mechanism we use for the
custom srccfg mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:36:50 +02:00
mwiegand
778f98dedf
spec(textarea-editor-v2): commit CodeMirror 6 design
Approved 2026-05-17. Supersedes 2026-05-16-textarea-code-editor-design.md.

Architecture: CodeMirror 6 (bundled via esbuild, committed to
static/vendor/). Form-bridge: submit-time copy (cm6 owns the doc;
capture-phase submit handler writes textarea.value once; JSON-save
path calls controller.getValue()). srccfg grammar via StreamLanguage;
bash via @codemirror/lang-bash; autocomplete via @codemirror/autocomplete.
Vocab generated from the existing repo-root ./cvar_list (2196 entries).
Theme follows the site's prefers-color-scheme model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:35:27 +02:00
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
mwiegand
f14d352657
revert(editor): roll back textarea code editor (re-architecture in flight)
The contenteditable + CodeJar + Prism approach (Tasks 1-12 + 4 smoke
fixes shipped this session) hit too many contenteditable edge cases to
ship:

- Copy collapses multi-line selections to one line (Selection.toString()
  doesn't reliably reconstruct newlines across Prism's tokenized <span>
  topology).
- Enter sometimes requires two presses + cursor color shifts (caret
  lands "between" sibling tokenized spans; first Enter shifts it into
  a real text node, second actually inserts).
- Cascade of earlier bugs already fixed (cursor jumped to start, then
  end; popup-accepted-quote duplicated; popup didn't accept at
  end-of-line) were all symptoms of the same root cause: manual Range
  API manipulation against tokenized contenteditable DOM is unreliable.

Exiting the sunk-cost path before more fixes accrue. The next attempt
will be a fresh brainstorming session weighing CodeMirror 6 (battle-
tested, accepts a one-time bundler step) vs textarea-overlay (real
<textarea> for editing, passive <pre> highlight, no contenteditable).

Kept (informs the next attempt):
- spec + plan documents in docs/superpowers/
- Playwright scaffolding (conftest + smoke test) + dev deps + e2e marker
- scripts/dev-server.py (independent of editor approach)
- AGENTS.md sandbox + Chromium Mach-port notes

Removed:
- editor JS (editor.js, srccfg-grammar.js)
- editor CSS (editor.css)
- vendored CodeJar + Prism + README
- srccfg vocab data
- editor partial (_editor_assets.html)
- template wiring (data-editor-language attributes, asset partial includes,
  files-editor language <select>)
- files-overlay.js editor bridge (setEditorContent helper, dropdown
  listener, filename-handler auto-redetect, dropdown reset)
- tokens.css syntax-color additions (dead without the editor)
- form-contract tests in test_blueprints.py + test_script_overlay_routes.py
- the editor-specific Playwright test (test_editor.py)
- create-blueprint modal trim that was tied to editor UX (Arguments +
  Config textareas restored)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:53:26 +02:00
mwiegand
ead4bd1aa4
feat(scripts): add scripts/dev-server.py for local UI smoke
Codifies the local-smoke setup so future inspection of the l4d2web UI
on macOS is reproducible without a real deploy. Sets the
production-equivalent env vars the systemd unit normally gets from
/etc/left4me/*.env:

- SECRET_KEY (dev placeholder)
- DATABASE_URL → .tmp/dev-server/l4d2web.db (gitignored)
- LEFT4ME_ROOT → .tmp/dev-server (so overlay-mkdir doesn't try
  /var/lib/left4me, which is read-only on macOS dev)
- SESSION_COOKIE_SECURE=0 so cookies survive http://127.0.0.1
- JOB_WORKER_ENABLED=false so the background worker doesn't shell out
  to the sudo-requiring production l4d2ctl

Runs alembic upgrade head. On first run, auto-seeds:

- admin user 'dev' / 'devdevdev' (password chosen to satisfy the ≥8
  char policy in l4d2web/auth.py:validate_new_password)
- one blueprint with example srccfg content (exercises the
  highlighter + autocomplete)
- one script overlay with bash (exercises the bash highlighter)
- one files overlay with a test.cfg (exercises the files-editor
  modal + language dropdown)
- one server linked to the blueprint (exercises the server detail
  page rendering, though deploy actions still fail)

Starts Flask with --debug so code + template changes auto-reload.
Stub l4d2ctl for server-deploy actions is deliberately out of scope;
documented in the script's docstring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:04:11 +02:00
mwiegand
338b7baff3
feat(blueprint): strip create-modal to name-only
The new-blueprint modal had Name + Arguments + Config textareas, but
the modal lives on blueprints.html (the list page), not on
blueprint_detail.html, so neither textarea was wired to the srccfg
editor — mixing themed-editor and raw-textarea UX in the same flow.
Keep just Name; arguments/config are edited on the detail page where
the editor lives. Add autofocus to the name field for keyboard flow.

Server contract unchanged: create_blueprint (blueprint_routes.py:80)
already defaults arguments/config to [] when absent from the form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:03:57 +02:00
mwiegand
bee0f07d2f
fix(editor): drop prism.css to unblock dark-mode rendering
Prism's stock theme has

    code[class*=language-] { color:#000; background:transparent; }

at specificity (0,1,1), which beats our .editor-code (0,1,0). Result:
the editor's background was transparent and base text was #000, leaving
black-on-dark text in dark mode (unreadable).

We override every Prism token class we use (.token.comment / .string /
.keyword / .number / .operator / .identifier) via theme-aware
--color-* tokens defined in both :root and the
@media (prefers-color-scheme: dark) block of tokens.css, so prism.css
contributes nothing of value. Drop the <link> from _editor_assets.html.
Flip the form-contract tests to assert prism.css is NOT in the body so
a future accidental re-add is caught.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:03:48 +02:00
mwiegand
9a773093a8
fix(editor): correct caret behavior in autocomplete accept + disable auto-close
Four smoke-discovered fixes in acceptCompletion / CodeJar options:

- Backward selection walk via Selection.modify replaces the buggy
  range.setStart(endContainer, endOffset - fragment.length). The old
  code assumed the fragment lived in endContainer; at end-of-line the
  caret often sits in a post-Prism-<span> text node, so the subtraction
  went negative → IndexSizeError → caught silently → popup dismissed
  with no insert.
- Save/restore caret around updateCode because codejar.js:469-474 does
  editor.textContent = code; highlight(editor) with no caret preservation,
  which dropped the caret to the start of the editor.
- Set the selection inside the inserted text node before save() so
  CodeJar's save() doesn't trip its anchorNode === editor special case
  at codejar.js:122-127, which collapses to end-of-all-text.
- addClosing: false on both CodeJar constructors so closing quotes
  don't duplicate — CodeJar's addClosing: true default inserts a paired
  closing character without skipping past an existing one, producing
  e.g. "rcon_password"" when you finish a string literal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:03:37 +02:00
mwiegand
5bec91ab17
perf(overlay): only ship editor assets to pages that mount an editor
Addresses Important #2 from the final code review.

The asset partial was previously included unconditionally for any
overlay detail page. Workshop overlays and read-only files-type
overlays (when the viewer isn't the owner or admin) have no
data-editor-language textarea, so the ~30 KB of Prism + CodeJar + JS
+ CSS shipped pointlessly. Gate the include on the two cases that
actually mount an editor: script-type overlays (bash editor) and
files-type overlays where the current user has edit rights (the
files-editor modal).

I-1 from the review (race window during "Loading…") was confirmed
moot — editorDialog.showModal() only fires after the fetch resolves
(files-overlay.js:409), so the dialog is invisible during the
fetch-and-placeholder window and the user can't type into it.

I-3 (Playwright coverage of the language dropdown override) is real
follow-up work that needs a new files-type overlay seed in the
live_server fixture. Deferred as a v2 ticket.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:25:44 +02:00