Compare commits

..

No commits in common. "ead4bd1aa4019f4468bad74c82d1e19c52eb3fe6" and "e5ce4e9fc8c9a2ad03dc09fbfffadb340b306a21" have entirely different histories.

26 changed files with 12 additions and 3414 deletions

View file

@ -97,22 +97,3 @@ Typical commands (once components exist):
- If a requested change conflicts with this file, follow explicit user instruction.
- If plans and code diverge, update plans or flag the mismatch clearly.
## End-to-end tests
The Playwright-based browser tests under `l4d2web/tests/e2e/` need a
chromium binary, fetched on first setup:
```bash
uv run playwright install chromium
```
Run with `uv run pytest -m e2e`. Excluded from the default fast suite
via the `e2e` marker.
**Sandbox note:** Chromium needs Mach-port IPC on macOS, which the
Claude Code sandbox blocks. When running e2e tests from a sandboxed
agent session, pass `dangerouslyDisableSandbox: true` on the
`uv run pytest -m e2e` invocation (the symptom of a sandboxed run is
a `FATAL` Chromium crash with `Permission denied (1100)` on Mach port
rendezvous, not a missing-binary or network error).

File diff suppressed because it is too large Load diff

View file

@ -1,175 +0,0 @@
# Textarea Code Editor (Highlighting + Autocomplete) Design
**Goal:** Upgrade three plain-text `<textarea>` fields in left4me into a
lightweight code editor with syntax highlighting and identifier-as-you-type
autocomplete. The motivating case is the blueprint config field, where users
edit Source-engine `server.cfg`-style content but have to remember hundreds of
L4D2 cvar names from memory.
**Architecture:** One reusable vanilla-JS widget. Each `<textarea>` opts in via
a `data-editor-language` attribute. The textarea stays in the DOM as the value
carrier — the widget mounts a `contenteditable` sibling that mirrors content
back into the textarea on every input, so HTML form submission and existing
JS that reads `textarea.value` keep working unchanged. Library stack is two
single-file self-hosted dependencies (CodeJar + Prism) plus a small custom
grammar — no bundler, no build step.
---
## Call sites
| Template | Line | Language | Save path |
|---|---|---|---|
| `l4d2web/l4d2web/templates/blueprint_detail.html` | 52 | `srccfg` (preset) | HTML form POST → `blueprint_routes.update_blueprint_form` |
| `l4d2web/l4d2web/templates/overlay_detail.html` | 25 | `bash` (preset) | HTML form POST → overlay script update route |
| `l4d2web/l4d2web/templates/overlay_detail.html` | 178 | `auto` (filename-derived; dropdown override) | `fetch('/files/save', {path, content})` from `files-overlay.js:450-557` |
## Widget contract
Every textarea with `data-editor-language="<lang>"` gets upgraded on
`DOMContentLoaded`:
- Sibling `<div class="editor-shell">` holding `<code class="editor-code"
contenteditable="true">`, initialized with the textarea's value.
- Textarea set to `display: none` (kept in DOM as form/value carrier).
- CodeJar mounted on the `<code>`, with highlighter →
`Prism.highlightElement` for the active language.
- CodeJar `onUpdate(code)` writes back to `textarea.value` and dispatches an
`input` event on the textarea.
- Floating autocomplete `<ul>` positioned at the caret via
`getSelection().getRangeAt(0).getBoundingClientRect()`.
- Exposes `setLanguage(name)` for the files-editor language dropdown.
If JS fails to bootstrap, the textarea is shown (matching today's behavior) —
graceful no-JS fallback for free.
## Autocomplete behavior
- **Trigger:** word fragment before caret matches `[A-Za-z0-9_]{2,}` and has
≥1 hit in the active language's vocabulary.
- **Filter:** case-insensitive prefix match first, then substring match. Cap
50 candidates; render top 8 with scroll for the rest.
- **Keys (popup open):** ↑/↓ navigate · Tab/Enter accept · Esc close · any
other key continues editing and re-filters.
- **Mouse:** click to accept.
- **Display:** identifier line + muted description line if `desc` present.
## Languages
- **`srccfg`** — custom Prism grammar (~30 lines). Tokens: comment (`//…`),
string (`"…"` with escapes), number, keyword (`exec | alias | bind`),
identifier. Purely visual — no semantic validation.
- **`bash`** — Prism's stock `bash` grammar. No project-owned code.
- **`auto`** — sentinel resolved on mount from the filename: `.cfg → srccfg`,
`.sh → bash`, otherwise `plain`. Re-evaluated when the filename input
changes (only while the dropdown is in its initial auto state).
- **`plain`** — no highlighter, no autocomplete. The widget still mounts so
the language dropdown remains usable; setting language back to `srccfg`
or `bash` re-activates highlighting.
## Vocabulary
Lives at `l4d2web/l4d2web/static/data/srccfg-vocab.json`:
```json
{
"cvars": [{"name": "sv_cheats", "desc": "Allow cheat cvars (0/1)"}, …],
"commands": [{"name": "exec", "desc": "Execute a .cfg file"}, …]
}
```
**Sourcing:** dump `cvarlist` / `cmdlist` from a freshly-started L4D2
server with the project's common SourceMod plugins loaded. Hand-trim engine
internals nobody touches. Descriptions come from the `cvarlist` output's
trailing help text where present; otherwise omitted. Generated once,
committed verbatim. Document the regeneration procedure in the top-of-file
comment of `srccfg-vocab.json`.
**Loading:** lazy fetch on first `srccfg` editor mount; cached on
`window.__srccfgVocab` so multiple editors on the same page share the load.
## Asset layout (new)
```
l4d2web/l4d2web/static/
vendor/
prism.js # prismjs.com custom build: core + clike + bash, pinned
prism.css
codejar.js # github.com/antonmedv/codejar release, pinned
README.md # source URLs + versions + SHA256 per file
js/
editor.js # widget: mount, popup, sync, language switch
srccfg-grammar.js # Prism.languages.srccfg
data/
srccfg-vocab.json # curated cvars + commands
```
All vendored files are self-hosted; the existing CSP
(`l4d2web/l4d2web/app.py:99` — `default-src 'self'; script-src 'self'
'nonce-…'`) requires it. Template `<script>` tags use `nonce="{{ g.csp_nonce }}"`
per the established pattern (`app.py:86`).
## Files Touched
| File | Change |
|---|---|
| `l4d2web/l4d2web/templates/blueprint_detail.html` | Add `data-editor-language="srccfg"` to line 52; include editor asset block |
| `l4d2web/l4d2web/templates/overlay_detail.html` | Add `data-editor-language="bash"` to line 25; add `data-editor-language="auto"` + language `<select>` near line 178 filename field; include editor asset block |
| `l4d2web/l4d2web/templates/_editor_assets.html` | New Jinja partial: the five script/link tags with nonces |
| `l4d2web/l4d2web/static/vendor/prism.{js,css}` | New (vendored) |
| `l4d2web/l4d2web/static/vendor/codejar.js` | New (vendored) |
| `l4d2web/l4d2web/static/vendor/README.md` | New — versions + SHA256s |
| `l4d2web/l4d2web/static/js/editor.js` | New — ~250 LOC widget |
| `l4d2web/l4d2web/static/js/srccfg-grammar.js` | New — ~30 LOC Prism grammar |
| `l4d2web/l4d2web/static/data/srccfg-vocab.json` | New — curated cvars/commands |
| `l4d2web/l4d2web/static/css/editor.css` | New — dedicated stylesheet for `.editor-shell`, `.editor-code`, `.editor-popup`; reuses `tokens.css` color variables; preserves textarea visual to avoid layout shift. Loaded by the `_editor_assets.html` partial so it ships only on pages that mount an editor. |
| `l4d2web/pyproject.toml` | Add `playwright` dev dep |
| `l4d2web/tests/e2e/conftest.py` | New — boot Flask app under test, expose URL fixture |
| `l4d2web/tests/e2e/test_editor.py` | New — Playwright test: type `sv_che`, assert popup, accept, assert textarea value |
| `l4d2web/tests/test_blueprints.py` | Extend — assert editor attrs in GET, assert form contract on POST |
**Untouched by design:**
`l4d2web/l4d2web/blueprint_routes.py`, `l4d2web/l4d2web/static/js/files-overlay.js`,
`l4d2web/l4d2web/app.py` — the form contract and the JSON-fetch save path
remain identical, which is the central reason this design uses
"textarea-as-value-carrier" rather than replacing the textarea.
## Reusable patterns referenced
- `l4d2web/l4d2web/static/js/blueprint-overlay-picker.js:20-26,63`
progressive enhancement via DOM-manipulated form fields. This editor
follows the same pattern (no submit interception, native form serialization).
- `l4d2web/l4d2web/app.py:86``g.csp_nonce` accessor for template `<script>`
tags.
- `l4d2web/l4d2web/static/js/files-overlay.js:450-557` — JSON-fetch save
path that continues to read `textarea.value` unchanged.
## Verification
Manual (Chrome MCP during development):
1. `/blueprints/<id>` — textarea replaced by editor; existing content preserved.
2. Type `sv_che` → popup shows `sv_cheats`; Tab accepts; line highlights.
3. Submit form; reload; saved value matches editor content.
4. Script-type overlay → bash highlighting.
5. Files-type overlay → create `test.cfg`, srccfg highlighting; dropdown to
bash, re-highlights; save; persisted content matches.
6. Disable JS, reload blueprint → textarea visible, form submits.
7. View-source → no inline scripts, all assets nonce'd.
Automated:
- `cd l4d2web && uv run pytest tests/test_blueprints.py`
- `cd l4d2web && uv run pytest -m e2e` (Playwright)
## Open / Closed
- **Closed in v1:** line numbers, search-in-editor, multi-cursor, bracket
matching, theme switching, SourcePawn highlighting, bash buffer-context
autocomplete, per-server live `cvarlist` capture.
- **Open as additive future work:** line numbers (deferred per explicit
scope decision; cheap to add later as a non-wrapping mode for `srccfg` +
`bash`); SourcePawn grammar; per-server cvar augmentation.
- **No backend changes.** The design is intentionally constrained to static
assets + template attributes so the change is small to review and easy to
revert by deleting the asset block.

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,77 +0,0 @@
"""Pytest fixtures for end-to-end browser tests.
Boots the Flask app in a background thread on an ephemeral port and
yields the base URL. The app uses a temp SQLite DB so e2e runs don't
contaminate the dev database.
"""
import socket
import threading
import pytest
from werkzeug.serving import make_server
from l4d2web.app import create_app
from l4d2web.auth import hash_password
from l4d2web.db import init_db, session_scope
from l4d2web.models import Blueprint, User
def _free_port() -> int:
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
return port
@pytest.fixture(scope="function")
def live_server(tmp_path, monkeypatch):
db_path = tmp_path / "e2e.db"
db_url = f"sqlite:///{db_path}"
monkeypatch.setenv("DATABASE_URL", db_url)
# app.py:57 sets SESSION_COOKIE_SECURE = not TESTING, which would
# mark the session cookie Secure. The browser then drops it over
# http://127.0.0.1 in e2e tests and the login flow silently fails
# with a redirect back to /login. Force it off explicitly via the
# env-var override (app.py:53-55) rather than flipping TESTING,
# which would skip the SECRET_KEY guard and other production paths.
monkeypatch.setenv("SESSION_COOKIE_SECURE", "0")
app = create_app({"TESTING": False, "DATABASE_URL": db_url, "SECRET_KEY": "e2e"})
# create_app() already calls init_db() inside an app context, which
# binds tables to the in-app engine. The seed work below uses
# session_scope() OUTSIDE any app context, which reads DATABASE_URL
# from the environment and binds its own engine. This second init_db()
# call creates the tables on that env-derived engine so the seed
# inserts have somewhere to land.
init_db()
with session_scope() as session:
user = User(
username="alice",
password_digest=hash_password("secret"),
admin=False,
)
session.add(user)
session.flush()
bp = Blueprint(
user_id=user.id, name="bp", arguments="[]", config="[]"
)
session.add(bp)
session.flush()
blueprint_id = bp.id
user_id = user.id
port = _free_port()
server = make_server("127.0.0.1", port, app)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield {
"base_url": f"http://127.0.0.1:{port}",
"user_id": user_id,
"blueprint_id": blueprint_id,
}
finally:
server.shutdown()
thread.join(timeout=2)

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

@ -1,9 +0,0 @@
import pytest
@pytest.mark.e2e
def test_live_server_boots(live_server) -> None:
import urllib.request
resp = urllib.request.urlopen(live_server["base_url"] + "/login")
assert resp.status == 200

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

View file

@ -16,15 +16,8 @@ l4d2host = { workspace = true }
l4d2web = { workspace = true }
[dependency-groups]
dev = [
"pytest",
"playwright>=1.49.0",
"pytest-playwright>=0.6.0",
]
dev = ["pytest"]
[tool.pytest.ini_options]
testpaths = ["l4d2host/tests", "l4d2web/tests"]
addopts = ["--import-mode=importlib", "-m", "not e2e"]
markers = [
"e2e: end-to-end browser tests (slow, require chromium)",
]
addopts = ["--import-mode=importlib"]

View file

@ -1,165 +0,0 @@
#!/usr/bin/env python3
"""Local dev server for inspecting the l4d2web UI without a real deploy.
Sets the same env vars the production systemd unit gets from
/etc/left4me/*.env, runs alembic upgrade head, and starts Flask. First
run also seeds an admin user + a tiny demo content set (one blueprint,
one script overlay, one files overlay) so the editor is visible at all
three call sites immediately.
Does NOT mock l4d2ctl, so server-deploy actions (initialize/start/stop)
still fail they need real systemd + steamcmd. Blueprint + overlay
CRUD work fine.
Usage: scripts/dev-server.py [--port N] (default: 5051)
Reset: rm -rf .tmp/dev-server (next run reseeds)
"""
import json
import os
import pathlib
import sqlite3
import subprocess
import sys
from datetime import UTC, datetime
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
DEV_ROOT = REPO_ROOT / ".tmp" / "dev-server"
DB_FILE = DEV_ROOT / "l4d2web.db"
def seed_demo_content():
"""Insert one blueprint, one script overlay, one files overlay, one server."""
overlay_root = DEV_ROOT / "overlays"
conn = sqlite3.connect(DB_FILE)
cur = conn.cursor()
admin_id = cur.execute(
"SELECT id FROM users WHERE admin = 1 LIMIT 1"
).fetchone()[0]
now = datetime.now(UTC).isoformat()
blueprint_id = None # captured below; needed for the server row
config_lines = [
"// L4D2 dev demo — try the srccfg highlighter + autocomplete",
"sv_cheats 0",
"mp_gamemode coop",
"z_difficulty Normal",
"// Type sv_ on a new line to see the cvar popup:",
"",
]
cur.execute(
"INSERT INTO blueprints "
"(user_id, name, arguments, config, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(admin_id, "demo-srccfg", json.dumps([]),
json.dumps(config_lines), now, now),
)
blueprint_id = cur.lastrowid
script_text = (
"#!/usr/bin/env bash\n"
"# L4D2 dev demo — try the bash highlighter\n"
"set -euo pipefail\n"
"\n"
"for f in /overlay/*.cfg; do\n"
' echo "processing $f"\n'
"done\n"
)
cur.execute(
"INSERT INTO overlays (name, path, type, user_id, script, "
"last_build_status, created_at, updated_at) "
"VALUES (?, ?, 'script', NULL, ?, '', ?, ?)",
("demo-bash", "_pending", script_text, now, now),
)
script_id = cur.lastrowid
cur.execute("UPDATE overlays SET path = ? WHERE id = ?",
(str(script_id), script_id))
(overlay_root / str(script_id)).mkdir(parents=True, exist_ok=True)
cur.execute(
"INSERT INTO overlays (name, path, type, user_id, script, "
"last_build_status, created_at, updated_at) "
"VALUES (?, ?, 'files', NULL, '', '', ?, ?)",
("demo-files", "_pending", now, now),
)
files_id = cur.lastrowid
cur.execute("UPDATE overlays SET path = ? WHERE id = ?",
(str(files_id), files_id))
files_dir = overlay_root / str(files_id)
files_dir.mkdir(parents=True, exist_ok=True)
(files_dir / "test.cfg").write_text(
"// open this file in the editor modal — flip the language\n"
"// dropdown to bash to see the runtime language switch.\n"
"sv_cheats 0\n"
)
# Server: links the blueprint above to a port. desired_state stays
# "stopped" so the GUI shows it without trying to deploy via l4d2ctl.
cur.execute(
"INSERT INTO servers (user_id, blueprint_id, name, port, "
"desired_state, actual_state, last_error, created_at, updated_at) "
"VALUES (?, ?, 'demo-server', 27015, 'stopped', 'unknown', '', ?, ?)",
(admin_id, blueprint_id, now, now),
)
server_id = cur.lastrowid
conn.commit()
conn.close()
print(f" blueprint id={blueprint_id} 'demo-srccfg' -> /blueprints/{blueprint_id}")
print(f" overlay id={script_id} 'demo-bash' (script) -> /overlays/{script_id}")
print(f" overlay id={files_id} 'demo-files' (files) -> /overlays/{files_id}")
print(f" server id={server_id} 'demo-server' (:27015) -> /servers/{server_id}")
def main():
port = 5051
if "--port" in sys.argv:
port = int(sys.argv[sys.argv.index("--port") + 1])
DEV_ROOT.mkdir(parents=True, exist_ok=True)
(DEV_ROOT / "overlays").mkdir(exist_ok=True)
(DEV_ROOT / "instances").mkdir(exist_ok=True)
# Production-equivalent env (systemd gets these from /etc/left4me/*.env).
os.environ.update({
"SECRET_KEY": "local-dev-only-not-a-secret",
"DATABASE_URL": f"sqlite:///{DB_FILE}",
"LEFT4ME_ROOT": str(DEV_ROOT),
"SESSION_COOKIE_SECURE": "0", # so http://127.0.0.1 cookies stick
"JOB_WORKER_ENABLED": "false", # don't fire (sudo-requiring) l4d2ctl
})
print(f"==> Migrating {DB_FILE} to head", flush=True)
subprocess.run(["uv", "run", "alembic", "upgrade", "head"],
cwd=REPO_ROOT / "l4d2web", check=True, stdout=subprocess.DEVNULL)
user_count = sqlite3.connect(DB_FILE).execute(
"SELECT COUNT(*) FROM users").fetchone()[0]
admin_user = os.environ.get("LEFT4ME_DEV_ADMIN_USERNAME", "dev")
admin_pw = os.environ.get("LEFT4ME_DEV_ADMIN_PASSWORD", "devdevdev")
if user_count == 0:
print(f"==> Seeding admin user '{admin_user}'", flush=True)
subprocess.run(
["uv", "run", "flask", "--app", "l4d2web.app:create_app()",
"create-user", admin_user, "--admin"],
cwd=REPO_ROOT, check=True,
env={**os.environ, "LEFT4ME_ADMIN_PASSWORD": admin_pw},
)
print("==> Seeding demo content", flush=True)
seed_demo_content()
print(f"\n admin: {admin_user} / {admin_pw}", flush=True)
print(f" db: {DB_FILE}", flush=True)
print(f" root: {DEV_ROOT}", flush=True)
print(f"\n Ctrl-C to stop. Reset all state: rm -rf .tmp/dev-server\n", flush=True)
print(f"==> Starting Flask on http://127.0.0.1:{port}", flush=True)
# --debug enables: Python code reload on save, template auto-reload,
# verbose error pages, the Werkzeug interactive debugger. Safe here
# because we bind to 127.0.0.1 only.
os.execvp("uv", ["uv", "run", "flask",
"--app", "l4d2web.app:create_app()",
"--debug", "run", "--port", str(port)])
if __name__ == "__main__":
main()

94
uv.lock
View file

@ -154,9 +154,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/58/fc576f99037ce19c5aa16628e4c3226b6d1419f72a62c79f5f40576e6eb3/greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", size = 285066, upload-time = "2026-04-27T12:23:05.033Z" },
{ url = "https://files.pythonhosted.org/packages/4a/ba/b28ddbe6bfad6a8ac196ef0e8cff37bc65b79735995b9e410923fffeeb70/greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", size = 604414, upload-time = "2026-04-27T12:52:42.358Z" },
{ url = "https://files.pythonhosted.org/packages/09/06/4b69f8f0b67603a8be2790e55107a190b376f2627fe0eaf5695d85ffb3cd/greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", size = 617349, upload-time = "2026-04-27T12:59:43.32Z" },
{ url = "https://files.pythonhosted.org/packages/6a/15/a643b4ecd09969e30b8a150d5919960caae0abe4f5af75ab040b1ab85e78/greenlet-3.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d", size = 623234, upload-time = "2026-04-27T13:02:40.611Z" },
{ url = "https://files.pythonhosted.org/packages/8a/17/a3918541fd0ddefe024a69de6d16aa7b46d36ac19562adaa63c7fa180eff/greenlet-3.5.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13", size = 613927, upload-time = "2026-04-27T12:25:30.28Z" },
{ url = "https://files.pythonhosted.org/packages/77/18/3b13d5ef1275b0ffaf933b05efa21408ac4ca95823c7411d79682e4fdcff/greenlet-3.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae", size = 425243, upload-time = "2026-04-27T13:05:15.689Z" },
{ url = "https://files.pythonhosted.org/packages/ee/e1/bd0af6213c7dd33175d8a462d4c1fe1175124ebed4855bc1475a5b5242c2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba", size = 1570893, upload-time = "2026-04-27T12:53:29.483Z" },
{ url = "https://files.pythonhosted.org/packages/9b/2a/0789702f864f5382cb476b93d7a9c823c10472658102ccd65f415747d2e2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846", size = 1636060, upload-time = "2026-04-27T12:25:28.845Z" },
{ url = "https://files.pythonhosted.org/packages/b2/8f/22bf9df92bbff0eb07842b60f7e63bf7675a9742df628437a9f02d09137f/greenlet-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5", size = 238740, upload-time = "2026-04-27T12:24:01.341Z" },
@ -164,9 +162,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/5e/a70f31e3e8d961c4ce589c15b28e4225d63704e431a23932a3808cbcc867/greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8", size = 285564, upload-time = "2026-04-27T12:23:08.555Z" },
{ url = "https://files.pythonhosted.org/packages/af/a6/046c0a28e21833e4086918218cfb3d8bed51c075a1b700f20b9d7861c0f4/greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1", size = 651166, upload-time = "2026-04-27T12:52:43.644Z" },
{ url = "https://files.pythonhosted.org/packages/47/f8/4af27f71c5ff32a7fbc516adb46370d9c4ae2bc7bd3dc7d066ac542b4b15/greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3", size = 663792, upload-time = "2026-04-27T12:59:44.93Z" },
{ url = "https://files.pythonhosted.org/packages/fb/89/2dadb89793c37ee8b4c237857188293e9060dc085f19845c292e00f8e091/greenlet-3.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37", size = 668086, upload-time = "2026-04-27T13:02:42.314Z" },
{ url = "https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7", size = 660933, upload-time = "2026-04-27T12:25:33.276Z" },
{ url = "https://files.pythonhosted.org/packages/82/35/75722be7e26a2af4cbd2dc35b0ed382dacf9394b7e75551f76ed1abe87f2/greenlet-3.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2", size = 470799, upload-time = "2026-04-27T13:05:17.094Z" },
{ url = "https://files.pythonhosted.org/packages/83/e4/b903e5a5fae1e8a28cdd32a0cfbfd560b668c25b692f67768822ddc5f40f/greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf", size = 1618401, upload-time = "2026-04-27T12:53:31.062Z" },
{ url = "https://files.pythonhosted.org/packages/0e/e3/5ec408a329acb854fb607a122e1ee5fb3ff649f9a97952948a90803c0d8e/greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16", size = 1682038, upload-time = "2026-04-27T12:25:31.838Z" },
{ url = "https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", size = 239835, upload-time = "2026-04-27T12:24:54.136Z" },
@ -174,9 +170,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/78/a8/4522939255bb5409af4e87132f915446bf3622c2c292d14d3c38d128ae82/greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853", size = 293614, upload-time = "2026-04-27T12:24:12.874Z" },
{ url = "https://files.pythonhosted.org/packages/15/5e/8744c52e2c027b5a8772a01561934c8835f869733e101f62075c60430340/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f", size = 650723, upload-time = "2026-04-27T12:52:45.412Z" },
{ url = "https://files.pythonhosted.org/packages/00/ef/7b4c39c03cf46ceca512c5d3f914afd85aa30b2cc9a93015b0dd73e4be6c/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7", size = 656529, upload-time = "2026-04-27T12:59:46.295Z" },
{ url = "https://files.pythonhosted.org/packages/5f/5c/0602239503b124b70e39355cbdb39361ecfe65b87a5f2f63752c32f5286f/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce", size = 657015, upload-time = "2026-04-27T13:02:43.973Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b5/c7768f352f5c010f92064d0063f987e7dc0cd290a6d92a34109015ce4aa1/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112", size = 654364, upload-time = "2026-04-27T12:25:35.64Z" },
{ url = "https://files.pythonhosted.org/packages/38/51/8699f865f125dc952384cb432b0f7138aa4d8f2969a7d12d0df5b94d054d/greenlet-3.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2", size = 488275, upload-time = "2026-04-27T13:05:18.28Z" },
{ url = "https://files.pythonhosted.org/packages/ef/d0/079ebe12e4b1fc758857ce5be1a5e73f06870f2101e52611d1e71925ce54/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2", size = 1614204, upload-time = "2026-04-27T12:53:32.618Z" },
{ url = "https://files.pythonhosted.org/packages/6d/89/6c2fb63df3596552d20e58fb4d96669243388cf680cff222758812c7bfaa/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2", size = 1675480, upload-time = "2026-04-27T12:25:34.168Z" },
{ url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" },
@ -284,9 +278,7 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "playwright" },
{ name = "pytest" },
{ name = "pytest-playwright" },
]
[package.metadata]
@ -296,11 +288,7 @@ requires-dist = [
]
[package.metadata.requires-dev]
dev = [
{ name = "playwright", specifier = ">=1.49.0" },
{ name = "pytest" },
{ name = "pytest-playwright", specifier = ">=0.6.0" },
]
dev = [{ name = "pytest" }]
[[package]]
name = "mako"
@ -396,25 +384,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "playwright"
version = "1.59.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet" },
{ name = "pyee" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/48/abab23f40643b4de8f2665816f0a1bf0994eeecda39d6d62f0f292b2ad01/playwright-1.59.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:bfc6940100b57423175c819ce2422ec5880d55fa2769987f62ab7a1f5fe6783e", size = 43156922, upload-time = "2026-04-29T08:11:08.921Z" },
{ url = "https://files.pythonhosted.org/packages/08/71/5e4d98b2ce3641b4343623c6450ff33b9de1c979d12a957505e392338b07/playwright-1.59.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:af068143a0c045ec11608b67d6c42e58db7e9cf65a742dd21fddedc1a9802c47", size = 41947177, upload-time = "2026-04-29T08:11:12.867Z" },
{ url = "https://files.pythonhosted.org/packages/80/91/fd219aa78ca03d37e93aaedaed4e224131e3090a9264f9bb773c8271d67e/playwright-1.59.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:4a4a2d4842b0e4120de3fa48636e4b69085a05b81d8a35ad4353f530ade72ed6", size = 43156922, upload-time = "2026-04-29T08:11:16.595Z" },
{ url = "https://files.pythonhosted.org/packages/73/0c/1e513d37c5be07d12829ebce93dbfe7baee230084cb66966c423432799c4/playwright-1.59.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c5792aad9e22b91a09264b9edbc18553cf05ea5a39404d65dc19a012c6b2e51d", size = 47151793, upload-time = "2026-04-29T08:11:19.979Z" },
{ url = "https://files.pythonhosted.org/packages/a3/2d/15f72288cb65d690134e18fefb9483cc4976f7579b580648c45e494481a7/playwright-1.59.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c881a19377d2b900af855fb525b5f22a27bf3cfbecba6d1edb36766d56cb100", size = 46877615, upload-time = "2026-04-29T08:11:23.863Z" },
{ url = "https://files.pythonhosted.org/packages/72/a1/717ac5bc99f387c0f60def91271ea4262125c0815d764a5d1776a272275c/playwright-1.59.0-py3-none-win32.whl", hash = "sha256:6989c476be2b9cd3e24a18cc9dcf202e266fb3d91e3e5395cd668c54ea54b119", size = 37713698, upload-time = "2026-04-29T08:11:27.251Z" },
{ url = "https://files.pythonhosted.org/packages/0f/a5/4e630ee05d8b46b840f943268e86d6063703e8dadb2d3eb405c7b9b2e48c/playwright-1.59.0-py3-none-win_amd64.whl", hash = "sha256:d5a5cc064b82ca92996080025710844e417f44df8fda9001102c28f44174171c", size = 37713704, upload-time = "2026-04-29T08:11:30.41Z" },
{ url = "https://files.pythonhosted.org/packages/eb/0c/3ece41761ba13c8321009aefcaec7a016eb42799c42eef5e03ace7f2de5b/playwright-1.59.0-py3-none-win_arm64.whl", hash = "sha256:93581ad515728cadc8af39b288a5633ba6d36e7d72048e79d890ce01ea2156f9", size = 33956745, upload-time = "2026-04-29T08:11:34.738Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@ -424,18 +393,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pyee"
version = "13.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
@ -461,46 +418,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "pytest-base-url"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/1a/b64ac368de6b993135cb70ca4e5d958a5c268094a3a2a4cac6f0021b6c4f/pytest_base_url-2.1.0.tar.gz", hash = "sha256:02748589a54f9e63fcbe62301d6b0496da0d10231b753e950c63e03aee745d45", size = 6702, upload-time = "2024-01-31T22:43:00.81Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/1c/b00940ab9eb8ede7897443b771987f2f4a76f06be02f1b3f01eb7567e24a/pytest_base_url-2.1.0-py3-none-any.whl", hash = "sha256:3ad15611778764d451927b2a53240c1a7a591b521ea44cebfe45849d2d2812e6", size = 5302, upload-time = "2024-01-31T22:42:58.897Z" },
]
[[package]]
name = "pytest-playwright"
version = "0.7.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "playwright" },
{ name = "pytest" },
{ name = "pytest-base-url" },
{ name = "python-slugify" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e8/6b/913e36aa421b35689ec95ed953ff7e8df3f2ee1c7b8ab2a3f1fd39d95faf/pytest_playwright-0.7.2.tar.gz", hash = "sha256:247b61123b28c7e8febb993a187a07e54f14a9aa04edc166f7a976d88f04c770", size = 16928, upload-time = "2025-11-24T03:43:22.53Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/61/4d333d8354ea2bea2c2f01bad0a4aa3c1262de20e1241f78e73360e9b620/pytest_playwright-0.7.2-py3-none-any.whl", hash = "sha256:8084e015b2b3ecff483c2160f1c8219b38b66c0d4578b23c0f700d1b0240ea38", size = 16881, upload-time = "2025-11-24T03:43:24.423Z" },
]
[[package]]
name = "python-slugify"
version = "8.0.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "text-unidecode" },
]
sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
@ -613,15 +530,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" },
]
[[package]]
name = "text-unidecode"
version = "1.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" },
]
[[package]]
name = "typer"
version = "0.25.1"