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>
This commit is contained in:
mwiegand 2026-05-17 00:53:26 +02:00
parent ead4bd1aa4
commit f14d352657
No known key found for this signature in database
17 changed files with 9 additions and 1291 deletions

View file

@ -1,81 +0,0 @@
/* Code editor widget paired with editor.js. Mounted as a sibling of
any <textarea data-editor-language>. The textarea is hidden but stays
in the DOM for form submission and JS .value reads. */
/* Token swaps from spec to match tokens.css:
--color-fg --color-text (text color token)
--color-bg-input --color-surface (textarea uses --color-surface)
--radius-input --radius-s (closest available radius token)
--color-bg-popover --color-surface (surface layer for overlays)
*/
.editor-shell {
position: relative;
width: 100%;
}
.editor-code {
display: block;
width: 100%;
min-height: 6em;
padding: var(--space-s) var(--space-m);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.875rem;
line-height: 1.5;
color: var(--color-text, #e6e6e6);
background: var(--color-surface, #1b1b1b);
border: 1px solid var(--color-border, #333);
border-radius: var(--radius-s, 4px);
white-space: pre-wrap;
word-break: break-word;
overflow: auto;
outline: none;
}
.editor-code:focus {
border-color: var(--color-focus, #6ab0ff);
}
/* Prism token colors — override defaults to match the site palette. */
.editor-code .token.comment { color: var(--color-muted, #888); font-style: italic; }
.editor-code .token.string { color: var(--color-string); }
.editor-code .token.keyword { color: var(--color-keyword); font-weight: 600; }
.editor-code .token.number { color: var(--color-number); }
.editor-code .token.operator { color: var(--color-muted, #888); }
.editor-code .token.identifier { color: inherit; }
/* Autocomplete popup. */
.editor-popup {
position: absolute;
z-index: 1000;
max-height: 14em;
overflow-y: auto;
min-width: 14em;
margin: 0;
padding: 0.25em 0;
list-style: none;
font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, monospace);
font-size: 0.9em;
background: var(--color-surface, #222);
border: 1px solid var(--color-border, #333);
border-radius: var(--radius-s, 4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.editor-popup-item {
padding: 0.25em 0.75em;
cursor: pointer;
white-space: nowrap;
}
.editor-popup-item.is-active {
background: var(--color-bg-popover-active);
}
.editor-popup-item .name { color: var(--color-text, #e6e6e6); }
.editor-popup-item .desc { color: var(--color-muted, #888); margin-left: 0.5em; }
/* Files-editor language dropdown. */
.editor-language-select {
margin-left: 0.5em;
}

View file

@ -13,10 +13,6 @@
--color-focus: #2563eb;
--color-log-bg: #f8fafc;
--color-log-text: #18181b;
--color-string: #0a3069;
--color-keyword: #cf222e;
--color-number: #0550ae;
--color-bg-popover-active: #e5e7eb;
--space-base: 0.25rem;
--space-xs: var(--space-base);
@ -55,10 +51,6 @@
--color-focus: #bfdbfe;
--color-log-bg: #111827;
--color-log-text: #e5e7eb;
--color-string: #a5d6ff;
--color-keyword: #ff7b72;
--color-number: #79c0ff;
--color-bg-popover-active: #374151;
}
}

View file

@ -1,45 +0,0 @@
{
"_comment": "Curated L4D2 cvars + commands for editor autocomplete. Regenerate by running `cvarlist` and `cmdlist` against a freshly-started L4D2 dedicated server with the project's common SourceMod plugins loaded, then hand-trimming engine internals nobody touches. Descriptions come from the trailing help text where present.",
"cvars": [
{"name": "sv_cheats", "desc": "Allow cheat cvars (0/1) — disables VAC"},
{"name": "sv_pure", "desc": "Pure-server enforcement (0=off, 1=loose, 2=strict)"},
{"name": "sv_consistency", "desc": "Force consistency on every client file (0/1)"},
{"name": "sv_alltalk", "desc": "Cross-team voice chat (0/1)"},
{"name": "sv_lan", "desc": "LAN-only server (0=internet, 1=LAN)"},
{"name": "sv_voiceenable", "desc": "Enable voice chat (0/1)"},
{"name": "sv_password", "desc": "Server join password (empty for open)"},
{"name": "sv_logflush", "desc": "Flush log file after every line (0/1)"},
{"name": "sv_minrate", "desc": "Minimum client bandwidth (bytes/sec)"},
{"name": "sv_maxrate", "desc": "Maximum client bandwidth (bytes/sec)"},
{"name": "sv_mincmdrate", "desc": "Minimum client command rate"},
{"name": "sv_maxcmdrate", "desc": "Maximum client command rate"},
{"name": "sv_minupdaterate", "desc": "Minimum server update rate"},
{"name": "sv_maxupdaterate", "desc": "Maximum server update rate"},
{"name": "sv_region", "desc": "Server browser region code"},
{"name": "sv_steamgroup", "desc": "Steam group ID for restricted servers"},
{"name": "sv_tags", "desc": "Comma-separated tags for the server browser"},
{"name": "hostname", "desc": "Server name shown in the browser"},
{"name": "rcon_password", "desc": "Remote-console admin password"},
{"name": "mp_gamemode", "desc": "Game mode (coop, versus, survival, scavenge, realism)"},
{"name": "mp_roundtime", "desc": "Round time limit (minutes)"},
{"name": "z_difficulty", "desc": "AI director difficulty (Easy/Normal/Hard/Impossible)"},
{"name": "director_no_specials", "desc": "Disable special-infected spawning (0/1)"},
{"name": "director_no_bosses", "desc": "Disable tank/witch spawning (0/1)"},
{"name": "director_panic_forever", "desc": "Endless horde panic event (0/1)"},
{"name": "nb_update_frequency", "desc": "Infected bot AI tick frequency"},
{"name": "fps_max", "desc": "Frame rate cap (0=uncapped)"},
{"name": "tickrate", "desc": "Server tickrate (engine-dependent ceiling)"},
{"name": "net_splitpacket_maxrate", "desc": "Maximum split-packet bandwidth"},
{"name": "decalfrequency", "desc": "Anti-spam delay between sprays (seconds)"}
],
"commands": [
{"name": "exec", "desc": "Execute a .cfg file"},
{"name": "alias", "desc": "Define a console-command alias"},
{"name": "bind", "desc": "Bind a key to a command"},
{"name": "unbind", "desc": "Remove a key binding"},
{"name": "toggle", "desc": "Flip a 0/1 cvar"},
{"name": "sm_cvar", "desc": "SourceMod: set a cvar bypassing sv_cheats restrictions"},
{"name": "echo", "desc": "Print to console"},
{"name": "say", "desc": "Send a chat message as the server"}
]
}

View file

@ -1,403 +0,0 @@
// Code editor widget. Mounts on any <textarea data-editor-language>.
// The textarea stays in the DOM (display:none) and the widget mirrors
// content back into it on every input — form submission and JS code
// that reads textarea.value (e.g. files-overlay.js) keep working.
//
// Public per-instance API (attached to the textarea as ._codeEditor):
// - setValue(text)
// - setLanguage(name) // "srccfg" | "bash" | "plain" | "auto"
// - getValue()
// - destroy()
(function () {
"use strict";
const LANG_BY_EXT = {
cfg: "srccfg",
sh: "bash",
bash: "bash",
};
const VOCAB_URLS = {
srccfg: "/static/data/srccfg-vocab.json",
};
const vocabCache = {};
async function loadVocab(lang) {
if (vocabCache[lang]) return vocabCache[lang];
const url = VOCAB_URLS[lang];
if (!url) return [];
try {
const r = await fetch(url);
if (!r.ok) return [];
const data = await r.json();
const merged = []
.concat(data.cvars || [])
.concat(data.commands || []);
vocabCache[lang] = merged;
return merged;
} catch (err) {
console.warn("[editor] vocab load failed for " + lang, err);
vocabCache[lang] = [];
return [];
}
}
function resolveAutoLanguage(filename) {
if (!filename) return "plain";
const m = /\.([a-zA-Z0-9]+)$/.exec(filename);
if (!m) return "plain";
return LANG_BY_EXT[m[1].toLowerCase()] || "plain";
}
function highlightFor(lang) {
return function (editorEl) {
if (lang === "plain" || !window.Prism || !window.Prism.languages[lang]) {
// Plain mode or grammar missing: leave textContent alone.
editorEl.innerHTML = editorEl.textContent
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;");
return;
}
window.Prism.highlightElement(editorEl);
};
}
const WORD_BEFORE_CARET = /[A-Za-z0-9_]{2,}$/;
function getCaretContext(codeEl) {
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return null;
const range = sel.getRangeAt(0).cloneRange();
if (!codeEl.contains(range.endContainer)) return null;
// Build the text from start of the editor up to the caret.
const pre = range.cloneRange();
pre.selectNodeContents(codeEl);
pre.setEnd(range.endContainer, range.endOffset);
const textBefore = pre.toString();
const m = WORD_BEFORE_CARET.exec(textBefore);
if (!m) return null;
return {
fragment: m[0],
rect: range.getBoundingClientRect(),
};
}
function filterVocab(vocab, fragment) {
const lower = fragment.toLowerCase();
const prefix = [];
const substr = [];
for (const entry of vocab) {
const name = entry.name.toLowerCase();
if (name.startsWith(lower)) prefix.push(entry);
else if (name.includes(lower)) substr.push(entry);
if (prefix.length + substr.length >= 50) break;
}
return prefix.concat(substr).slice(0, 50);
}
function renderPopup(popup, items, activeIndex) {
popup.innerHTML = "";
const visible = items.slice(0, 8);
visible.forEach((entry, i) => {
const li = document.createElement("li");
li.className = "editor-popup-item" + (i === activeIndex ? " is-active" : "");
li.dataset.index = String(i);
const name = document.createElement("span");
name.className = "name";
name.textContent = entry.name;
li.appendChild(name);
if (entry.desc) {
const desc = document.createElement("span");
desc.className = "desc";
desc.textContent = "— " + entry.desc;
li.appendChild(desc);
}
popup.appendChild(li);
});
}
function positionPopup(popup, rect) {
popup.style.left = (window.scrollX + rect.left) + "px";
popup.style.top = (window.scrollY + rect.bottom + 2) + "px";
}
// For "auto" language: look for a filename input inside the nearest
// enclosing dialog or .modal. Returns the <input> or null.
// Intentionally does NOT walk up to <body>: an "auto" textarea
// outside a modal scope degrades to "plain" rather than picking up
// some unrelated .files-editor-filename elsewhere in the document.
function findFilenameInput(textarea) {
const scope = textarea.closest("dialog, .modal");
if (!scope) return null;
return scope.querySelector(".files-editor-filename");
}
function mount(textarea) {
if (textarea._codeEditor) return textarea._codeEditor;
const requested = textarea.dataset.editorLanguage || "plain";
let language =
requested === "auto"
? resolveAutoLanguage(findFilenameInput(textarea)?.value)
: requested;
// Build the visible editor.
const shell = document.createElement("div");
shell.className = "editor-shell";
const code = document.createElement("code");
code.className = "editor-code language-" + language;
code.setAttribute("contenteditable", "true");
code.setAttribute("spellcheck", "false");
code.textContent = textarea.value;
shell.appendChild(code);
textarea.parentNode.insertBefore(shell, textarea);
textarea.style.display = "none";
// CodeJar mounts on the contenteditable and re-runs the highlighter
// on each input while preserving caret position.
const jar = window.CodeJar(code, highlightFor(language), { tab: " ", addClosing: false });
function attachOnUpdate(jarInstance) {
jarInstance.onUpdate(function (value) {
// Mirror back to the underlying textarea so form POST and any
// .value readers see the current content.
textarea.value = value;
textarea.dispatchEvent(new Event("input", { bubbles: true }));
});
}
attachOnUpdate(jar);
// Warm the vocab cache so the first keystroke doesn't race a network
// fetch. Fire-and-forget; loadVocab already caches the empty result
// on failure so this is idempotent.
if (language !== "plain") loadVocab(language);
let popup = null;
let popupItems = [];
let popupActive = 0;
function ensurePopup() {
if (popup) return popup;
popup = document.createElement("ul");
popup.className = "editor-popup";
popup.style.display = "none";
document.body.appendChild(popup);
popup.addEventListener("mousedown", function (e) {
e.preventDefault(); // keep caret in editor
const li = e.target.closest(".editor-popup-item");
if (!li) return;
acceptCompletion(popupItems[parseInt(li.dataset.index, 10)]);
});
return popup;
}
function hidePopup() {
if (popup) popup.style.display = "none";
popupItems = [];
}
function acceptCompletion(entry) {
if (!entry) return;
const ctx = getCaretContext(code);
if (!ctx) {
hidePopup();
return;
}
// Walk the selection focus backwards `fragment.length` characters
// and replace the result with the chosen identifier.
//
// We can't use `range.setStart(endContainer, endOffset - fragment.length)`
// because at end-of-line the caret often sits in a text node AFTER
// Prism's <span class="token …"> (the trailing newline / empty text
// node), so `endOffset` is small and the subtraction would go
// negative or land in the wrong node. `Selection.modify` walks
// across node boundaries the way the browser's own caret-movement
// logic does, so it works regardless of which DOM node the caret
// ended up in after the last Prism rehighlight.
//
// (Selection.modify is well-supported in all evergreen browsers
// even though it's not standardised — same family of API as
// contenteditable itself.)
try {
const sel = window.getSelection();
for (let i = 0; i < ctx.fragment.length; i++) {
sel.modify("extend", "backward", "character");
}
const range = sel.getRangeAt(0);
range.deleteContents();
const insertedNode = document.createTextNode(entry.name);
range.insertNode(insertedNode);
// Move the caret to inside the inserted text node (at its end),
// NOT just "after" it via range.collapse(false). The latter
// leaves anchorNode/focusNode pointing at <code> with an offset
// — and CodeJar's save() has a special case (codejar.js:122-127)
// that collapses any such selection to end-of-all-text, sending
// restore() to the wrong place. Pointing the selection inside
// the inserted text node keeps save() on its normal walk path.
const caretRange = document.createRange();
caretRange.setStart(insertedNode, insertedNode.length);
caretRange.setEnd(insertedNode, insertedNode.length);
sel.removeAllRanges();
sel.addRange(caretRange);
// Force CodeJar to re-highlight + emit onUpdate. updateCode does
// `editor.textContent = code; highlight(editor)` — no caret
// preservation — so we save the linear-text offset of the caret
// (now positioned after the inserted identifier) before and
// restore after. Use instance.jar (not bare `jar` closure) for
// consistency with setLanguage's tear-down-and-remount: the jar
// reference is swapped at runtime.
const caretPos = instance.jar.save();
instance.jar.updateCode(instance.jar.toString());
instance.jar.restore(caretPos);
} catch (err) {
console.warn("[editor] acceptCompletion failed; dismissing popup", err);
}
hidePopup();
}
async function refreshPopup() {
if (instance.language === "plain") {
hidePopup();
return;
}
const ctx = getCaretContext(code);
if (!ctx) {
hidePopup();
return;
}
const vocab = await loadVocab(instance.language);
if (!vocab.length) {
hidePopup();
return;
}
const filtered = filterVocab(vocab, ctx.fragment);
if (!filtered.length) {
hidePopup();
return;
}
popupItems = filtered;
popupActive = 0;
ensurePopup();
renderPopup(popup, popupItems, popupActive);
positionPopup(popup, ctx.rect);
popup.style.display = "";
}
code.addEventListener("input", refreshPopup);
code.addEventListener("blur", function () {
// Defer hide so a popup click can still register.
setTimeout(hidePopup, 100);
});
// IMPORTANT: capture-phase listener.
//
// CodeJar registers its own keydown handler on this same element during
// construction (window.CodeJar at line ~159 above), and it uses
// preventDefault + insert-tab-spaces on Tab and preventDefault +
// stopPropagation on Enter (with leading indent). Bubble-phase order =
// registration order: CodeJar runs first and consumes those keys before
// our popup handler ever sees them.
//
// Capture phase fires before bubble. By calling stopPropagation here,
// we prevent CodeJar's bubble handler from running for the keys we own
// while the popup is visible. Normal typing (popup hidden) still flows
// through to CodeJar unchanged because we early-return without
// stopPropagation in that case.
code.addEventListener("keydown", function (e) {
if (!popup || popup.style.display === "none" || !popupItems.length) return;
if (e.key === "ArrowDown") {
popupActive = (popupActive + 1) % Math.min(popupItems.length, 8);
renderPopup(popup, popupItems, popupActive);
e.preventDefault();
e.stopPropagation();
} else if (e.key === "ArrowUp") {
popupActive =
(popupActive - 1 + Math.min(popupItems.length, 8)) %
Math.min(popupItems.length, 8);
renderPopup(popup, popupItems, popupActive);
e.preventDefault();
e.stopPropagation();
} else if (e.key === "Tab" || e.key === "Enter") {
acceptCompletion(popupItems[popupActive]);
e.preventDefault();
e.stopPropagation();
} else if (e.key === "Escape") {
hidePopup();
e.preventDefault();
e.stopPropagation();
}
}, true);
const instance = {
textarea,
shell,
code,
jar,
language,
setValue: function (text) {
// updateCode does not invoke the onUpdate mirror, so fire the
// same textarea sync + input event here for consistency. Any
// listener watching the textarea sees external setValue calls
// (e.g. files-overlay loading a file into the modal) the same
// way it sees user typing.
instance.jar.updateCode(text);
textarea.value = text;
textarea.dispatchEvent(new Event("input", { bubbles: true }));
},
getValue: function () {
return instance.jar.toString();
},
setLanguage: function (name) {
const next =
name === "auto"
? resolveAutoLanguage(findFilenameInput(textarea)?.value)
: name;
if (next === instance.language) return;
// CodeJar captures its highlight callback by closure at
// construction time — there is no API to swap it on a live
// instance. Tear down and remount with the new highlighter.
// Caret position is lost on switch; acceptable since this is
// triggered by the user clicking the language dropdown.
const currentText = instance.jar.toString();
instance.jar.destroy();
instance.language = next;
code.className = "editor-code language-" + next;
code.textContent = currentText;
instance.jar = window.CodeJar(code, highlightFor(next), { tab: " ", addClosing: false });
attachOnUpdate(instance.jar);
},
destroy: function () {
instance.jar.destroy();
if (popup) popup.remove();
shell.remove();
textarea.style.display = "";
delete textarea._codeEditor;
},
};
textarea._codeEditor = instance;
return instance;
}
function mountAll(root) {
const scope = root || document;
scope.querySelectorAll("textarea[data-editor-language]").forEach(mount);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () {
mountAll(document);
});
} else {
mountAll(document);
}
// Re-export for callers that need to mount editors created later
// (the files-editor modal exists in the static DOM but is only
// shown after user interaction — initial mount is correct, but
// exposing this hook lets future code mount dynamically-inserted
// editors if needed).
window.l4d2Editor = { mount, mountAll };
})();

View file

@ -281,16 +281,6 @@
saveBtn: editorDialog.querySelector(".files-editor-save"),
};
function setEditorContent(text) {
const editor = editorEls.contentBox._codeEditor;
if (editor) {
editor.setValue(text);
editor.setLanguage("auto"); // re-derive from filename
} else {
editorEls.contentBox.value = text;
}
}
function setEditorTitle(text) {
editorEls.title.textContent = text;
}
@ -349,17 +339,10 @@
editor.folder = folder;
editor.queuedReplacement = null;
// Reset the language dropdown to "auto" on every modal open so the
// displayed value matches what setEditorContent does internally
// (which always calls setLanguage("auto")). Without this, a user
// who picked a manual override on a previous open would see the
// stale selection while the editor language follows the new file.
if (languageSelect) languageSelect.value = "auto";
setEditorTitle(`${folder ? folder + "/" : ""}…new file`);
editorEls.filename.value = "";
editorEls.filename.disabled = false;
setEditorContent("");
editorEls.contentBox.value = "";
editorEls.contentBox.disabled = false;
editorEls.renameHint.hidden = true;
editorEls.textPanel.hidden = false;
@ -380,9 +363,6 @@
editor.queuedReplacement = null;
setQueuedReplacement(null);
// Reset the language dropdown — see openEditorTextNew for rationale.
if (languageSelect) languageSelect.value = "auto";
editorEls.filename.value = basename(path);
editorEls.filename.disabled = false;
editorEls.renameHint.hidden = true;
@ -395,14 +375,14 @@
editor.mode = "text";
editorEls.textPanel.hidden = false;
editorEls.binaryPanel.hidden = true;
setEditorContent("Loading…");
editorEls.contentBox.value = "Loading…";
editorEls.contentBox.disabled = true;
const r = await fetchJson(
`${baseUrl}/files/content?path=${encodeURIComponent(path)}`
);
if (r.ok && r.body) {
setEditorContent(r.body.content);
editorEls.contentBox.value = r.body.content;
editorEls.contentBox.disabled = false;
updateByteCount();
updateSaveEnabled();
@ -427,21 +407,9 @@
setTimeout(() => editorEls.filename.focus(), 0);
}
const languageSelect = document.querySelector(".files-editor-language");
if (languageSelect) {
languageSelect.addEventListener("change", function () {
const editor = editorEls.contentBox._codeEditor;
if (editor) editor.setLanguage(languageSelect.value);
});
}
editorEls.filename.addEventListener("input", () => {
updateRenameHint();
updateSaveEnabled();
const _editor = editorEls.contentBox._codeEditor;
if (_editor && languageSelect && languageSelect.value === "auto") {
_editor.setLanguage("auto");
}
});
editorEls.contentBox.addEventListener("input", () => {
updateByteCount();

View file

@ -1,17 +0,0 @@
// Prism grammar for Source-engine config files (server.cfg-style).
// Tokens: comment, string, number, keyword, identifier. Purely visual —
// no semantic validation of cvars or values.
(function (Prism) {
if (!Prism) return;
Prism.languages.srccfg = {
comment: /\/\/.*/,
string: {
pattern: /"(?:\\.|[^"\\])*"/,
greedy: true,
},
keyword: /\b(?:exec|alias|bind|unbind|toggle)\b/,
number: /\b\d+(?:\.\d+)?\b/,
identifier: /\b[a-zA-Z_][a-zA-Z0-9_]*\b/,
operator: /[+\-;]/,
};
})(typeof window !== "undefined" ? window.Prism : undefined);

View file

@ -1,28 +0,0 @@
# Vendored static assets
All third-party JS/CSS shipped under `/static/vendor/` is committed
verbatim from the upstream releases below. The strict CSP
(`default-src 'self'`) means we cannot load these from CDNs.
| File | Upstream | Version | SHA256 |
|---|---|---|---|
| `prism.js` | jsdelivr concat: prism-core.min.js + prism-clike.min.js + prism-bash.min.js | v1.29.0 | `636b6ce9db1eddd5b60992bb34e3fbc1c3364bce7c312f798f20a16011d7681c` |
| `prism.css` | https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism.min.css | v1.29.0 | `928e23e6b9fcef82c5f1d1f05b6f7fc5a6e187c60195e59fbf16fc9d071ee057` |
| `codejar.js` | https://cdn.jsdelivr.net/npm/codejar@4.0.0/dist/codejar.js + ESM-strip + attribution header + browser-global shim | v4.0.0 | `1d05dc0fdc6941c3ea0c865612843d098e271155b9258b4cbbc4095b07318d3b` |
CodeJar ships in source form (not minified) — chosen here over the
minified variant because the strict CSP rules out runtime sourcemap
loading from a CDN, and readable vendor source meaningfully helps
debugging when something goes wrong in the editor widget.
## Regenerating
- **Prism:** Re-run the three-component concat in Task 1 Step 1 of
`docs/superpowers/plans/2026-05-16-textarea-code-editor.md` with
an updated `VER`, then re-download the theme CSS.
- **CodeJar:** Re-download from jsdelivr per the same plan's Task 1
Step 2 (`dist/codejar.js`, not the bare `codejar.js` which does not
exist in v4.x), strip ESM exports, prepend the in-file attribution
header, re-append the `window.CodeJar` shim.
Bump the version + SHA columns in this table after any update.

View file

@ -1,492 +0,0 @@
/* CodeJar v4.0.0 — https://cdn.jsdelivr.net/npm/codejar@4.0.0/dist/codejar.js
* Local edits: leading `export` keyword stripped from `function CodeJar`;
* `window.CodeJar = CodeJar;` shim appended at EOF for non-module <script> use. */
const globalWindow = window;
function CodeJar(editor, highlight, opt = {}) {
const options = {
tab: '\t',
indentOn: /[({\[]$/,
moveToNewLine: /^[)}\]]/,
spellcheck: false,
catchTab: true,
preserveIdent: true,
addClosing: true,
history: true,
window: globalWindow,
...opt
};
const window = options.window;
const document = window.document;
let listeners = [];
let history = [];
let at = -1;
let focus = false;
let callback;
let prev; // code content prior keydown event
editor.setAttribute('contenteditable', 'plaintext-only');
editor.setAttribute('spellcheck', options.spellcheck ? 'true' : 'false');
editor.style.outline = 'none';
editor.style.overflowWrap = 'break-word';
editor.style.overflowY = 'auto';
editor.style.whiteSpace = 'pre-wrap';
let isLegacy = false; // true if plaintext-only is not supported
highlight(editor);
if (editor.contentEditable !== 'plaintext-only')
isLegacy = true;
if (isLegacy)
editor.setAttribute('contenteditable', 'true');
const debounceHighlight = debounce(() => {
const pos = save();
highlight(editor, pos);
restore(pos);
}, 30);
let recording = false;
const shouldRecord = (event) => {
return !isUndo(event) && !isRedo(event)
&& event.key !== 'Meta'
&& event.key !== 'Control'
&& event.key !== 'Alt'
&& !event.key.startsWith('Arrow');
};
const debounceRecordHistory = debounce((event) => {
if (shouldRecord(event)) {
recordHistory();
recording = false;
}
}, 300);
const on = (type, fn) => {
listeners.push([type, fn]);
editor.addEventListener(type, fn);
};
on('keydown', event => {
if (event.defaultPrevented)
return;
prev = toString();
if (options.preserveIdent)
handleNewLine(event);
else
legacyNewLineFix(event);
if (options.catchTab)
handleTabCharacters(event);
if (options.addClosing)
handleSelfClosingCharacters(event);
if (options.history) {
handleUndoRedo(event);
if (shouldRecord(event) && !recording) {
recordHistory();
recording = true;
}
}
if (isLegacy && !isCopy(event))
restore(save());
});
on('keyup', event => {
if (event.defaultPrevented)
return;
if (event.isComposing)
return;
if (prev !== toString())
debounceHighlight();
debounceRecordHistory(event);
if (callback)
callback(toString());
});
on('focus', _event => {
focus = true;
});
on('blur', _event => {
focus = false;
});
on('paste', event => {
recordHistory();
handlePaste(event);
recordHistory();
if (callback)
callback(toString());
});
on('cut', event => {
recordHistory();
handleCut(event);
recordHistory();
if (callback)
callback(toString());
});
function save() {
const s = getSelection();
const pos = { start: 0, end: 0, dir: undefined };
let { anchorNode, anchorOffset, focusNode, focusOffset } = s;
if (!anchorNode || !focusNode)
throw 'error1';
// If the anchor and focus are the editor element, return either a full
// highlight or a start/end cursor position depending on the selection
if (anchorNode === editor && focusNode === editor) {
pos.start = (anchorOffset > 0 && editor.textContent) ? editor.textContent.length : 0;
pos.end = (focusOffset > 0 && editor.textContent) ? editor.textContent.length : 0;
pos.dir = (focusOffset >= anchorOffset) ? '->' : '<-';
return pos;
}
// Selection anchor and focus are expected to be text nodes,
// so normalize them.
if (anchorNode.nodeType === Node.ELEMENT_NODE) {
const node = document.createTextNode('');
anchorNode.insertBefore(node, anchorNode.childNodes[anchorOffset]);
anchorNode = node;
anchorOffset = 0;
}
if (focusNode.nodeType === Node.ELEMENT_NODE) {
const node = document.createTextNode('');
focusNode.insertBefore(node, focusNode.childNodes[focusOffset]);
focusNode = node;
focusOffset = 0;
}
visit(editor, el => {
if (el === anchorNode && el === focusNode) {
pos.start += anchorOffset;
pos.end += focusOffset;
pos.dir = anchorOffset <= focusOffset ? '->' : '<-';
return 'stop';
}
if (el === anchorNode) {
pos.start += anchorOffset;
if (!pos.dir) {
pos.dir = '->';
}
else {
return 'stop';
}
}
else if (el === focusNode) {
pos.end += focusOffset;
if (!pos.dir) {
pos.dir = '<-';
}
else {
return 'stop';
}
}
if (el.nodeType === Node.TEXT_NODE) {
if (pos.dir != '->')
pos.start += el.nodeValue.length;
if (pos.dir != '<-')
pos.end += el.nodeValue.length;
}
});
// collapse empty text nodes
editor.normalize();
return pos;
}
function restore(pos) {
const s = getSelection();
let startNode, startOffset = 0;
let endNode, endOffset = 0;
if (!pos.dir)
pos.dir = '->';
if (pos.start < 0)
pos.start = 0;
if (pos.end < 0)
pos.end = 0;
// Flip start and end if the direction reversed
if (pos.dir == '<-') {
const { start, end } = pos;
pos.start = end;
pos.end = start;
}
let current = 0;
visit(editor, el => {
if (el.nodeType !== Node.TEXT_NODE)
return;
const len = (el.nodeValue || '').length;
if (current + len > pos.start) {
if (!startNode) {
startNode = el;
startOffset = pos.start - current;
}
if (current + len > pos.end) {
endNode = el;
endOffset = pos.end - current;
return 'stop';
}
}
current += len;
});
if (!startNode)
startNode = editor, startOffset = editor.childNodes.length;
if (!endNode)
endNode = editor, endOffset = editor.childNodes.length;
// Flip back the selection
if (pos.dir == '<-') {
[startNode, startOffset, endNode, endOffset] = [endNode, endOffset, startNode, startOffset];
}
s.setBaseAndExtent(startNode, startOffset, endNode, endOffset);
}
function beforeCursor() {
const s = getSelection();
const r0 = s.getRangeAt(0);
const r = document.createRange();
r.selectNodeContents(editor);
r.setEnd(r0.startContainer, r0.startOffset);
return r.toString();
}
function afterCursor() {
const s = getSelection();
const r0 = s.getRangeAt(0);
const r = document.createRange();
r.selectNodeContents(editor);
r.setStart(r0.endContainer, r0.endOffset);
return r.toString();
}
function handleNewLine(event) {
if (event.key === 'Enter') {
const before = beforeCursor();
const after = afterCursor();
let [padding] = findPadding(before);
let newLinePadding = padding;
// If last symbol is "{" ident new line
if (options.indentOn.test(before)) {
newLinePadding += options.tab;
}
// Preserve padding
if (newLinePadding.length > 0) {
preventDefault(event);
event.stopPropagation();
insert('\n' + newLinePadding);
}
else {
legacyNewLineFix(event);
}
// Place adjacent "}" on next line
if (newLinePadding !== padding && options.moveToNewLine.test(after)) {
const pos = save();
insert('\n' + padding);
restore(pos);
}
}
}
function legacyNewLineFix(event) {
// Firefox does not support plaintext-only mode
// and puts <div><br></div> on Enter. Let's help.
if (isLegacy && event.key === 'Enter') {
preventDefault(event);
event.stopPropagation();
if (afterCursor() == '') {
insert('\n ');
const pos = save();
pos.start = --pos.end;
restore(pos);
}
else {
insert('\n');
}
}
}
function handleSelfClosingCharacters(event) {
const open = `([{'"`;
const close = `)]}'"`;
if (open.includes(event.key)) {
preventDefault(event);
const pos = save();
const wrapText = pos.start == pos.end ? '' : getSelection().toString();
const text = event.key + wrapText + close[open.indexOf(event.key)];
insert(text);
pos.start++;
pos.end++;
restore(pos);
}
}
function handleTabCharacters(event) {
if (event.key === 'Tab') {
preventDefault(event);
if (event.shiftKey) {
const before = beforeCursor();
let [padding, start,] = findPadding(before);
if (padding.length > 0) {
const pos = save();
// Remove full length tab or just remaining padding
const len = Math.min(options.tab.length, padding.length);
restore({ start, end: start + len });
document.execCommand('delete');
pos.start -= len;
pos.end -= len;
restore(pos);
}
}
else {
insert(options.tab);
}
}
}
function handleUndoRedo(event) {
if (isUndo(event)) {
preventDefault(event);
at--;
const record = history[at];
if (record) {
editor.innerHTML = record.html;
restore(record.pos);
}
if (at < 0)
at = 0;
}
if (isRedo(event)) {
preventDefault(event);
at++;
const record = history[at];
if (record) {
editor.innerHTML = record.html;
restore(record.pos);
}
if (at >= history.length)
at--;
}
}
function recordHistory() {
if (!focus)
return;
const html = editor.innerHTML;
const pos = save();
const lastRecord = history[at];
if (lastRecord) {
if (lastRecord.html === html
&& lastRecord.pos.start === pos.start
&& lastRecord.pos.end === pos.end)
return;
}
at++;
history[at] = { html, pos };
history.splice(at + 1);
const maxHistory = 300;
if (at > maxHistory) {
at = maxHistory;
history.splice(0, 1);
}
}
function handlePaste(event) {
preventDefault(event);
const text = (event.originalEvent || event)
.clipboardData
.getData('text/plain')
.replace(/\r\n?/g, '\n');
const pos = save();
insert(text);
highlight(editor);
restore({
start: Math.min(pos.start, pos.end) + text.length,
end: Math.min(pos.start, pos.end) + text.length,
dir: '<-',
});
}
function handleCut(event) {
const pos = save();
const selection = getSelection();
const originalEvent = event.originalEvent ?? event;
originalEvent.clipboardData.setData("text/plain", selection.toString());
document.execCommand('delete');
highlight(editor);
restore({
start: pos.start,
end: pos.start,
dir: '->',
});
preventDefault(event);
}
function visit(editor, visitor) {
const queue = [];
if (editor.firstChild)
queue.push(editor.firstChild);
let el = queue.pop();
while (el) {
if (visitor(el) === 'stop')
break;
if (el.nextSibling)
queue.push(el.nextSibling);
if (el.firstChild)
queue.push(el.firstChild);
el = queue.pop();
}
}
function isCtrl(event) {
return event.metaKey || event.ctrlKey;
}
function isUndo(event) {
return isCtrl(event) && !event.shiftKey && getKeyCode(event) === 'Z';
}
function isRedo(event) {
return isCtrl(event) && event.shiftKey && getKeyCode(event) === 'Z';
}
function isCopy(event) {
return isCtrl(event) && getKeyCode(event) === 'C';
}
function getKeyCode(event) {
let key = event.key || event.keyCode || event.which;
if (!key)
return undefined;
return (typeof key === 'string' ? key : String.fromCharCode(key)).toUpperCase();
}
function insert(text) {
text = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
document.execCommand('insertHTML', false, text);
}
function debounce(cb, wait) {
let timeout = 0;
return (...args) => {
clearTimeout(timeout);
timeout = window.setTimeout(() => cb(...args), wait);
};
}
function findPadding(text) {
// Find beginning of previous line.
let i = text.length - 1;
while (i >= 0 && text[i] !== '\n')
i--;
i++;
// Find padding of the line.
let j = i;
while (j < text.length && /[ \t]/.test(text[j]))
j++;
return [text.substring(i, j) || '', i, j];
}
function toString() {
return editor.textContent || '';
}
function preventDefault(event) {
event.preventDefault();
}
function getSelection() {
if (editor.parentNode?.nodeType == Node.DOCUMENT_FRAGMENT_NODE) {
return editor.parentNode.getSelection();
}
return window.getSelection();
}
return {
updateOptions(newOptions) {
Object.assign(options, newOptions);
},
updateCode(code) {
editor.textContent = code;
highlight(editor);
if (callback)
callback(code);
},
onUpdate(cb) {
callback = cb;
},
toString,
save,
restore,
recordHistory,
destroy() {
for (let [type, fn] of listeners) {
editor.removeEventListener(type, fn);
}
},
};
}
// Browser global shim: surface CodeJar on window so non-module
// <script> tags can call it.
window.CodeJar = CodeJar;

View file

@ -1 +0,0 @@
code[class*=language-],pre[class*=language-]{color:#000;background:0 0;text-shadow:0 1px #fff;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}code[class*=language-] ::-moz-selection,code[class*=language-]::-moz-selection,pre[class*=language-] ::-moz-selection,pre[class*=language-]::-moz-selection{text-shadow:none;background:#b3d4fc}code[class*=language-] ::selection,code[class*=language-]::selection,pre[class*=language-] ::selection,pre[class*=language-]::selection{text-shadow:none;background:#b3d4fc}@media print{code[class*=language-],pre[class*=language-]{text-shadow:none}}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#f5f2f0}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#708090}.token.punctuation{color:#999}.token.namespace{opacity:.7}.token.boolean,.token.constant,.token.deleted,.token.number,.token.property,.token.symbol,.token.tag{color:#905}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#690}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url{color:#9a6e3a;background:hsla(0,0%,100%,.5)}.token.atrule,.token.attr-value,.token.keyword{color:#07a}.token.class-name,.token.function{color:#dd4a68}.token.important,.token.regex,.token.variable{color:#e90}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}

File diff suppressed because one or more lines are too long

View file

@ -1,16 +0,0 @@
{# Editor asset bundle — include on any page that mounts a
<textarea data-editor-language>. Order matters: prism + codejar load
first, then the srccfg grammar registers itself on window.Prism, then
editor.js scans the DOM and mounts.
prism.css is intentionally NOT loaded — its base `code[class*=language-]`
rule has specificity (0,1,1) which beats our `.editor-code` (0,1,0)
and forces color:#000 + background:transparent, which renders black
text on the dark-mode page background. We override every Prism token
class we use in editor.css with --color-* tokens that follow
prefers-color-scheme, so the default Prism theme adds nothing useful. #}
<link rel="stylesheet" href="{{ url_for('static', filename='css/editor.css') }}">
<script src="{{ url_for('static', filename='vendor/prism.js') }}" defer nonce="{{ g.csp_nonce }}"></script>
<script src="{{ url_for('static', filename='vendor/codejar.js') }}" defer nonce="{{ g.csp_nonce }}"></script>
<script src="{{ url_for('static', filename='js/srccfg-grammar.js') }}" defer nonce="{{ g.csp_nonce }}"></script>
<script src="{{ url_for('static', filename='js/editor.js') }}" defer nonce="{{ g.csp_nonce }}"></script>

View file

@ -49,7 +49,7 @@
<pre class="config-preview" aria-label="Auto-loaded overlay configs">{% for o in exposed %}exec {{ o.name }}.cfg
{% endfor %}</pre>
{% endif %}
<textarea name="config" rows="8" spellcheck="false" data-editor-language="srccfg">{{ config_lines | join('\n') }}</textarea>
<textarea name="config" rows="8" spellcheck="false">{{ config_lines | join('\n') }}</textarea>
</div>
</label>
<button type="submit">Save blueprint</button>
@ -92,5 +92,4 @@
</div>
</dialog>
<script src="{{ url_for('static', filename='js/blueprint-overlay-picker.js') }}" defer></script>
{% include '_editor_assets.html' %}
{% endblock %}

View file

@ -33,11 +33,9 @@
</div>
<div class="modal-body">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<label>Name <input name="name" required autofocus></label>
{# Arguments, config, and overlay assignments are edited on the
blueprint detail page where the srccfg editor + overlay picker
live. Keeping the create modal name-only avoids the conflict
where modal textareas can't host the editor cleanly. #}
<label>Name <input name="name" required></label>
<label>Arguments <textarea name="arguments" rows="8" spellcheck="false"></textarea></label>
<label>Config <textarea name="config" rows="8" spellcheck="false"></textarea></label>
</div>
<div class="modal-footer">
<button type="button" class="button-secondary" data-modal-close>Cancel</button>

View file

@ -22,7 +22,7 @@
<form method="post" action="/overlays/{{ overlay.id }}/script" class="stack">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<label>Bash script
<textarea name="script" rows="20" spellcheck="false" data-editor-language="bash">{{ overlay.script or "" }}</textarea>
<textarea name="script" rows="20" spellcheck="false">{{ overlay.script or "" }}</textarea>
</label>
<p class="muted">Runs sandboxed against the overlay directory mounted at <code>/overlay</code>.</p>
{% if not latest_build_is_running %}
@ -175,16 +175,7 @@
<div class="files-editor-text">
<label class="files-editor-field">
<span class="files-field-label">Content</span>
<textarea class="files-editor-content" rows="14" spellcheck="false" data-editor-language="auto"></textarea>
</label>
<label class="files-editor-field files-editor-language-field">
<span class="files-field-label">Language</span>
<select class="files-editor-language editor-language-select">
<option value="auto" selected>Auto (from filename)</option>
<option value="srccfg">Source config (.cfg)</option>
<option value="bash">Bash (.sh)</option>
<option value="plain">Plain text</option>
</select>
<textarea class="files-editor-content" rows="14" spellcheck="false"></textarea>
</label>
<div class="files-editor-meta muted">
<span class="files-editor-byte-count">UTF-8 · 0 bytes</span>
@ -282,11 +273,4 @@
<script src="{{ url_for('static', filename='js/files-overlay.js') }}" defer></script>
{% endif %}
{# Only include the ~30 KB of editor assets on pages that actually mount
an editor: script-type overlays (bash editor) and files-type overlays
that the current user can edit (the files-editor modal). Workshop
overlays and read-only pages skip the include entirely. #}
{% if overlay.type == 'script' or files_can_edit %}
{% include '_editor_assets.html' %}
{% endif %}
{% endblock %}

View file

@ -1,58 +0,0 @@
"""End-to-end test for the textarea code editor.
Logs in as the seed user, navigates to the blueprint detail page, types
`sv_che` into the editor, asserts the autocomplete popup appears with
`sv_cheats`, accepts via Tab, and asserts the underlying textarea now
contains `sv_cheats`.
"""
import pytest
from playwright.sync_api import expect, sync_playwright
@pytest.mark.e2e
def test_editor_autocomplete_inserts_cvar(live_server) -> None:
base = live_server["base_url"]
blueprint_id = live_server["blueprint_id"]
with sync_playwright() as p:
browser = p.chromium.launch()
ctx = browser.new_context()
page = ctx.new_page()
# Log in.
page.goto(f"{base}/login")
page.fill('input[name="username"]', "alice")
page.fill('input[name="password"]', "secret")
page.click('button[type="submit"]')
expect(page).to_have_url(f"{base}/dashboard", timeout=5000)
# Navigate to the seeded blueprint.
page.goto(f"{base}/blueprints/{blueprint_id}")
# Editor mounts on DOMContentLoaded; the contenteditable replaces
# the textarea visually. Wait for it.
editor = page.locator(".editor-code").first
expect(editor).to_be_visible(timeout=5000)
# Focus the editor and type a cvar prefix.
editor.click()
page.keyboard.type("sv_che")
# The popup should appear and contain sv_cheats.
popup = page.locator(".editor-popup")
expect(popup).to_be_visible(timeout=2000)
expect(popup).to_contain_text("sv_cheats")
# Accept via Tab. Verifies Task 9's capture-phase keydown fix:
# CodeJar would otherwise consume Tab and insert 2 spaces
# before our popup handler ran.
page.keyboard.press("Tab")
# The hidden textarea (form field) must now contain the cvar.
textarea_value = page.evaluate(
"() => document.querySelector('textarea[name=config]').value"
)
assert "sv_cheats" in textarea_value
browser.close()

View file

@ -253,62 +253,6 @@ def test_form_update_preserves_ordered_overlays_and_multiline_fields(user_client
assert update.headers["Location"] == "/blueprints/1"
def test_blueprint_detail_renders_editor_assets(user_client) -> None:
with session_scope() as session:
user = session.scalar(select(User).where(User.username == "alice"))
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
session.add(blueprint)
session.flush()
blueprint_id = blueprint.id
response = user_client.get(f"/blueprints/{blueprint_id}")
assert response.status_code == 200
body = response.get_data(as_text=True)
# Editor opts the textarea in via a data-attribute.
assert 'data-editor-language="srccfg"' in body
# All editor assets are referenced. prism.css intentionally not loaded
# — its default theme's `code[class*=language-]` selector beats our
# `.editor-code` background/color rules at higher specificity and
# breaks dark mode. We override every Prism token class we use in
# editor.css with theme-aware --color-* tokens instead.
assert "static/vendor/prism.js" in body
assert "static/vendor/codejar.js" in body
assert "static/js/srccfg-grammar.js" in body
assert "static/js/editor.js" in body
assert "static/css/editor.css" in body
assert "static/vendor/prism.css" not in body
# Scripts are nonce'd (CSP regression guard).
assert 'nonce="' in body
def test_blueprint_config_form_post_still_round_trips(user_client) -> None:
# The editor is a visual layer; the form POST contract must be
# unaffected. This guards against accidentally renaming `config` or
# dropping it from form serialization.
with session_scope() as session:
user = session.scalar(select(User).where(User.username == "alice"))
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
session.add(blueprint)
session.flush()
blueprint_id = blueprint.id
update = user_client.post(
f"/blueprints/{blueprint_id}",
data={
"name": "bp",
"arguments": "",
"config": "sv_cheats 1\nmp_gamemode coop",
"overlay_ids": [],
},
headers={"X-CSRF-Token": "test-token"},
)
assert update.status_code == 302
with session_scope() as session:
bp = session.get(Blueprint, blueprint_id)
assert json.loads(bp.config) == ["sv_cheats 1", "mp_gamemode coop"]
def test_blueprint_detail_renders_picker_list_and_select(user_client) -> None:
with session_scope() as session:
session.add(Overlay(name="o3", path="/opt/l4d2/overlays/o3"))

View file

@ -277,25 +277,3 @@ def test_permissions_admin_can_edit_any(app, alice_id, admin_id) -> None:
with session_scope() as s:
overlay = s.query(Overlay).filter_by(id=overlay_id).one()
assert overlay.script == "echo admin"
def test_script_overlay_detail_renders_bash_editor(app, alice_id) -> None:
overlay_id = _create_script_overlay(app, alice_id, name="bash-editor-test")
client = _client_for(app, alice_id)
response = client.get(f"/overlays/{overlay_id}")
assert response.status_code == 200
body = response.get_data(as_text=True)
# Editor opts the textarea in via a data-attribute.
assert 'data-editor-language="bash"' in body
# All editor assets are referenced. prism.css intentionally not loaded
# — see _editor_assets.html for the rationale (specificity conflict
# with our dark-mode tokens).
assert "static/vendor/prism.js" in body
assert "static/vendor/codejar.js" in body
assert "static/js/srccfg-grammar.js" in body
assert "static/js/editor.js" in body
assert "static/css/editor.css" in body
assert "static/vendor/prism.css" not in body
# Scripts are nonce'd (CSP regression guard).
assert 'nonce="' in body