Bite-sized, TDD-style plan for the spec at docs/superpowers/specs/2026-05-17-console-command-autocomplete-design.md. Seven tasks (rankVocab extraction → second esbuild target → vanilla dropdown module → stylesheet → wire-up → smoke test). Will be executed task-by-task via subagent-driven-development. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
725 lines
25 KiB
Markdown
725 lines
25 KiB
Markdown
# Console Command Autocomplete Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Add command/cvar autocomplete to the runtime console input on `server_detail.html`, sharing the editor's ranking algorithm via a pure-JS module compiled to a tiny additional bundle, with a vanilla dropdown that does not collide with the existing ArrowUp/Down history recall.
|
|
|
|
**Architecture:** Extract the editor's inlined ranking logic into a pure ES module `editor-src/vocab-rank.js`. The editor imports it directly; for the console, a second esbuild entry point bundles it into a small `static/vendor/vocab-rank.bundle.js` that exposes `window.__rankVocab`. A new `static/js/console-autocomplete.js` builds a vanilla dropdown (positioned absolutely under the console input), lazy-fetches `srccfg-vocab.json` on first focus, hides the dropdown once the user types past the first token, and binds Tab/Shift+Tab/Esc only — leaving ArrowUp/Down/Enter untouched for `console-history.js`.
|
|
|
|
**Tech Stack:** Vanilla JS (no framework), esbuild (IIFE bundles), CodeMirror 6 (editor-side only — console is plain `<input>`), HTMX (existing — for form submission and dynamic page-fragment swap), CSS variables defined in `tokens.css`/`editor.css`. Tests use Node's built-in `node:test` runner (no extra deps).
|
|
|
|
**Reference Spec:** `docs/superpowers/specs/2026-05-17-console-command-autocomplete-design.md`
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
**New files:**
|
|
- `l4d2web/scripts/editor-src/vocab-rank.js` — pure ranking module (ES, exports `rankVocab`)
|
|
- `l4d2web/scripts/editor-src/vocab-rank-entry.js` — IIFE entry that assigns `rankVocab` to `window.__rankVocab`
|
|
- `l4d2web/scripts/editor-src/vocab-rank.test.js` — Node `node:test` unit tests for the ranker
|
|
- `l4d2web/l4d2web/static/js/console-autocomplete.js` — vanilla dropdown, lazy fetch, key handling
|
|
- `l4d2web/l4d2web/static/css/console-autocomplete.css` — dropdown styling using existing CSS tokens
|
|
|
|
**Modified files:**
|
|
- `l4d2web/scripts/editor-src/autocomplete.js` — replace inlined `rank()` + scoring with `import { rankVocab } from "./vocab-rank.js"`
|
|
- `l4d2web/scripts/editor-src/package.json` — add `build:vocab-rank` script; chain into `build`
|
|
- `l4d2web/l4d2web/templates/base.html` — add `<script defer>` for `vocab-rank.bundle.js` and `console-autocomplete.js`; add `<link>` for `console-autocomplete.css`
|
|
|
|
**Build artifacts (regenerated, do not hand-edit):**
|
|
- `l4d2web/l4d2web/static/vendor/editor.bundle.js` — rebuilt because `autocomplete.js` changed
|
|
- `l4d2web/l4d2web/static/vendor/vocab-rank.bundle.js` — new tiny bundle
|
|
|
|
---
|
|
|
|
## Task 1: Extract `rankVocab` into a pure module (TDD)
|
|
|
|
**Goal:** Move the editor's inlined ranking logic into a standalone, testable, dependency-free function.
|
|
|
|
**Files:**
|
|
- Create: `l4d2web/scripts/editor-src/vocab-rank.js`
|
|
- Create: `l4d2web/scripts/editor-src/vocab-rank.test.js`
|
|
|
|
- [ ] **Step 1: Write the failing test file**
|
|
|
|
Create `l4d2web/scripts/editor-src/vocab-rank.test.js`:
|
|
|
|
```javascript
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { rankVocab } from "./vocab-rank.js";
|
|
|
|
const vocab = {
|
|
cvars: [
|
|
{ name: "sv_cheats", desc: "Allow cheats" },
|
|
{ name: "sv_gravity" },
|
|
{ name: "mp_friendlyfire", desc: "Toggle FF" },
|
|
],
|
|
commands: [
|
|
{ name: "kick", desc: "Kick a player" },
|
|
{ name: "kickall", desc: "Kick everyone" },
|
|
{ name: "changelevel", desc: "Change map" },
|
|
],
|
|
};
|
|
|
|
test("exact match comes first", () => {
|
|
const out = rankVocab("kick", vocab);
|
|
assert.equal(out[0].name, "kick");
|
|
assert.equal(out[1].name, "kickall");
|
|
});
|
|
|
|
test("prefix matches beat substring matches", () => {
|
|
const out = rankVocab("sv_", vocab);
|
|
assert.equal(out[0].name, "sv_cheats");
|
|
assert.equal(out[1].name, "sv_gravity");
|
|
// mp_friendlyfire contains no "sv_" → should not appear
|
|
assert.ok(!out.some(e => e.name === "mp_friendlyfire"));
|
|
});
|
|
|
|
test("substring matches included after prefix matches", () => {
|
|
// "iendly" is a substring of mp_friendlyfire but a prefix of nothing
|
|
const out = rankVocab("iendly", vocab);
|
|
assert.equal(out.length, 1);
|
|
assert.equal(out[0].name, "mp_friendlyfire");
|
|
});
|
|
|
|
test("kind is preserved on each result", () => {
|
|
const out = rankVocab("kick", vocab);
|
|
assert.equal(out[0].kind, "command");
|
|
const sv = rankVocab("sv_cheats", vocab);
|
|
assert.equal(sv[0].kind, "cvar");
|
|
});
|
|
|
|
test("desc is preserved when present", () => {
|
|
const out = rankVocab("kick", vocab);
|
|
assert.equal(out[0].desc, "Kick a player");
|
|
});
|
|
|
|
test("desc is undefined when source had no desc", () => {
|
|
const out = rankVocab("sv_gravity", vocab);
|
|
assert.equal(out[0].desc, undefined);
|
|
});
|
|
|
|
test("results are capped at the configured limit", () => {
|
|
const big = { cvars: [], commands: [] };
|
|
for (let i = 0; i < 200; i++) big.commands.push({ name: `cmd${i}` });
|
|
const out = rankVocab("cmd", big, { limit: 50 });
|
|
assert.equal(out.length, 50);
|
|
});
|
|
|
|
test("default limit is 50", () => {
|
|
const big = { cvars: [], commands: [] };
|
|
for (let i = 0; i < 200; i++) big.commands.push({ name: `cmd${i}` });
|
|
const out = rankVocab("cmd", big);
|
|
assert.equal(out.length, 50);
|
|
});
|
|
|
|
test("empty query returns no results", () => {
|
|
const out = rankVocab("", vocab);
|
|
assert.equal(out.length, 0);
|
|
});
|
|
|
|
test("case-insensitive match", () => {
|
|
const out = rankVocab("KICK", vocab);
|
|
assert.equal(out[0].name, "kick");
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
```bash
|
|
cd l4d2web/scripts/editor-src && node --test vocab-rank.test.js
|
|
```
|
|
|
|
Expected: FAIL with `Cannot find module './vocab-rank.js'` or `ERR_MODULE_NOT_FOUND`.
|
|
|
|
- [ ] **Step 3: Create the ranker module**
|
|
|
|
Create `l4d2web/scripts/editor-src/vocab-rank.js`:
|
|
|
|
```javascript
|
|
// Pure, dependency-free ranking of a vocabulary against a query string.
|
|
// Used by both the CodeMirror editor (via autocomplete.js) and the
|
|
// runtime console (via the vocab-rank bundle exposed on window).
|
|
//
|
|
// Score (lower = better):
|
|
// exact match → 0
|
|
// prefix match → 1 + label.length (shorter prefix matches win)
|
|
// substring match → 10000 + indexOf (earlier substring beats later)
|
|
// no match → -1 (excluded)
|
|
|
|
function score(query, label) {
|
|
if (label === query) return 0;
|
|
if (label.startsWith(query)) return 1 + label.length;
|
|
const i = label.indexOf(query);
|
|
if (i !== -1) return 10000 + i;
|
|
return -1;
|
|
}
|
|
|
|
export function rankVocab(query, vocab, { limit = 50 } = {}) {
|
|
if (!query) return [];
|
|
const q = query.toLowerCase();
|
|
|
|
const entries = [
|
|
...vocab.cvars.map(e => ({ ...e, kind: "cvar" })),
|
|
...vocab.commands.map(e => ({ ...e, kind: "command" })),
|
|
];
|
|
|
|
const scored = [];
|
|
for (const e of entries) {
|
|
const s = score(q, e.name.toLowerCase());
|
|
if (s === -1) continue;
|
|
scored.push([s, e]);
|
|
if (scored.length > limit * 4) break;
|
|
}
|
|
scored.sort((a, b) => a[0] - b[0]);
|
|
return scored.slice(0, limit).map(([, e]) => e);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
```bash
|
|
cd l4d2web/scripts/editor-src && node --test vocab-rank.test.js
|
|
```
|
|
|
|
Expected: PASS — 10 tests passing.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add l4d2web/scripts/editor-src/vocab-rank.js \
|
|
l4d2web/scripts/editor-src/vocab-rank.test.js
|
|
git commit -m "feat(editor): extract pure rankVocab module + tests"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Refactor `autocomplete.js` to use the shared ranker
|
|
|
|
**Goal:** Replace the inlined `rank()` and scoring loop in `autocomplete.js` with a call to `rankVocab`, with no behavior change.
|
|
|
|
**Files:**
|
|
- Modify: `l4d2web/scripts/editor-src/autocomplete.js`
|
|
|
|
- [ ] **Step 1: Rewrite `autocomplete.js`**
|
|
|
|
Replace the entire file contents with:
|
|
|
|
```javascript
|
|
import { autocompletion } from "@codemirror/autocomplete";
|
|
import { rankVocab } from "./vocab-rank.js";
|
|
|
|
const WORD_RE = /[A-Za-z0-9_]{2,}/;
|
|
|
|
export function vocabCompletions(vocab) {
|
|
// vocab: { cvars: [{name, desc?}, …], commands: [{name, desc?}, …] }
|
|
return (context) => {
|
|
const word = context.matchBefore(WORD_RE);
|
|
if (!word || (word.from === word.to && !context.explicit)) return null;
|
|
|
|
const ranked = rankVocab(word.text, vocab);
|
|
const options = ranked.map(e => ({
|
|
label: e.name,
|
|
info: e.desc || e.kind,
|
|
type: e.kind === "command" ? "function" : "variable",
|
|
}));
|
|
return { from: word.from, options, validFor: WORD_RE };
|
|
};
|
|
}
|
|
|
|
export function autocompleteExtension(vocab) {
|
|
return autocompletion({
|
|
override: [vocabCompletions(vocab)],
|
|
activateOnTyping: true,
|
|
maxRenderedOptions: 8,
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Rebuild the editor bundle**
|
|
|
|
```bash
|
|
cd l4d2web/scripts/editor-src && npm run build
|
|
```
|
|
|
|
Expected: `editor.bundle.js` regenerated in `l4d2web/l4d2web/static/vendor/`. No esbuild warnings or errors.
|
|
|
|
- [ ] **Step 3: Manually verify editor autocomplete still works (regression check)**
|
|
|
|
```bash
|
|
cd l4d2web && python ../scripts/dev-server.py
|
|
```
|
|
|
|
(Note: per memory, the dev server is `scripts/dev-server.py` at repo root, not `flask run`.) Then in a browser:
|
|
|
|
1. Open a server-detail page with a config file editor visible, or navigate to any `.cfg` file edit view.
|
|
2. In the editor, type `sv_` — autocomplete dropdown appears with cvars (e.g. `sv_cheats`, `sv_gravity`).
|
|
3. Type `sv_cheats` exactly — `sv_cheats` is first in the list.
|
|
4. Press Tab — completion is accepted.
|
|
|
|
Stop the dev server (Ctrl+C).
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add l4d2web/scripts/editor-src/autocomplete.js \
|
|
l4d2web/l4d2web/static/vendor/editor.bundle.js
|
|
git commit -m "refactor(editor): use shared rankVocab in autocomplete"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Build a standalone ranker bundle for the console
|
|
|
|
**Goal:** Produce `vocab-rank.bundle.js` — a tiny IIFE that exposes `window.__rankVocab` — so the non-bundled console-autocomplete.js can call the same ranker.
|
|
|
|
**Files:**
|
|
- Create: `l4d2web/scripts/editor-src/vocab-rank-entry.js`
|
|
- Modify: `l4d2web/scripts/editor-src/package.json`
|
|
|
|
- [ ] **Step 1: Create the IIFE entry point**
|
|
|
|
Create `l4d2web/scripts/editor-src/vocab-rank-entry.js`:
|
|
|
|
```javascript
|
|
import { rankVocab } from "./vocab-rank.js";
|
|
|
|
// Expose as a global function so plain (non-module) scripts on
|
|
// server_detail.html can call window.__rankVocab(query, vocab).
|
|
window.__rankVocab = rankVocab;
|
|
```
|
|
|
|
- [ ] **Step 2: Add a build script for it in `package.json`**
|
|
|
|
Open `l4d2web/scripts/editor-src/package.json` and replace the `"scripts"` block with:
|
|
|
|
```json
|
|
"scripts": {
|
|
"build:editor": "esbuild editor-entry.js --bundle --minify --format=iife --global-name=__editor_pkg --outfile=../../l4d2web/static/vendor/editor.bundle.js --metafile=meta.json",
|
|
"build:vocab-rank": "esbuild vocab-rank-entry.js --bundle --minify --format=iife --outfile=../../l4d2web/static/vendor/vocab-rank.bundle.js",
|
|
"build": "npm run build:editor && npm run build:vocab-rank"
|
|
},
|
|
```
|
|
|
|
- [ ] **Step 3: Run the build**
|
|
|
|
```bash
|
|
cd l4d2web/scripts/editor-src && npm run build
|
|
```
|
|
|
|
Expected: two output files updated/created. Verify with:
|
|
|
|
```bash
|
|
ls -la l4d2web/l4d2web/static/vendor/editor.bundle.js l4d2web/l4d2web/static/vendor/vocab-rank.bundle.js
|
|
```
|
|
|
|
Expected: `vocab-rank.bundle.js` exists (should be ~1-3 KB).
|
|
|
|
- [ ] **Step 4: Smoke-test the bundle from Node**
|
|
|
|
Quick check the bundle is well-formed (no syntax errors):
|
|
|
|
```bash
|
|
node -e 'const fs = require("fs"); const code = fs.readFileSync("l4d2web/l4d2web/static/vendor/vocab-rank.bundle.js", "utf8"); new Function("window", code)({}); console.log("ok");'
|
|
```
|
|
|
|
Expected: prints `ok` (means the IIFE parsed and ran).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add l4d2web/scripts/editor-src/vocab-rank-entry.js \
|
|
l4d2web/scripts/editor-src/package.json \
|
|
l4d2web/l4d2web/static/vendor/vocab-rank.bundle.js
|
|
git commit -m "feat(editor): build standalone vocab-rank bundle for console"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Build the console-autocomplete module
|
|
|
|
**Goal:** Create the vanilla-JS module that renders the dropdown, handles keyboard interaction, and binds to console forms (including HTMX-injected ones).
|
|
|
|
**Files:**
|
|
- Create: `l4d2web/l4d2web/static/js/console-autocomplete.js`
|
|
|
|
- [ ] **Step 1: Write the module**
|
|
|
|
Create `l4d2web/l4d2web/static/js/console-autocomplete.js`:
|
|
|
|
```javascript
|
|
// console-autocomplete.js
|
|
// Vanilla dropdown autocomplete for [data-console-form] inputs.
|
|
// Reads ranked completions from window.__rankVocab (loaded via
|
|
// vocab-rank.bundle.js). Owns: Tab, Shift+Tab, Esc, mouse events.
|
|
// Leaves: ArrowUp, ArrowDown, Enter (console-history.js owns those).
|
|
//
|
|
// First-token only: the dropdown is hidden as soon as the cursor
|
|
// is past the first space in the input.
|
|
|
|
const VOCAB_URL = "/static/data/srccfg-vocab.json";
|
|
const MAX_RENDERED = 8;
|
|
let vocabPromise = null;
|
|
|
|
function loadVocab() {
|
|
if (vocabPromise) return vocabPromise;
|
|
vocabPromise = fetch(VOCAB_URL, { credentials: "same-origin" })
|
|
.then(r => r.ok ? r.json() : Promise.reject(new Error("vocab fetch failed: " + r.status)))
|
|
.catch(err => { console.warn("[console-autocomplete] vocab load failed", err); return null; });
|
|
return vocabPromise;
|
|
}
|
|
|
|
function firstTokenSlice(value, caret) {
|
|
// Returns the substring [0, end-of-first-token) if the caret is
|
|
// within the first token; otherwise null.
|
|
const spaceIdx = value.indexOf(" ");
|
|
if (spaceIdx === -1) {
|
|
return { token: value, from: 0, to: value.length };
|
|
}
|
|
if (caret > spaceIdx) return null;
|
|
return { token: value.slice(0, spaceIdx), from: 0, to: spaceIdx };
|
|
}
|
|
|
|
function bindConsoleAutocomplete(form) {
|
|
if (form.dataset.consoleAutocompleteBound === "true") return;
|
|
form.dataset.consoleAutocompleteBound = "true";
|
|
|
|
const input = form.querySelector("input[name='command']");
|
|
if (!input) return;
|
|
|
|
// --- Dropdown DOM (created lazily on first show) ---
|
|
let dropdown = null;
|
|
let items = []; // current ranked items
|
|
let highlightIdx = 0; // index of currently-highlighted row
|
|
let vocab = null;
|
|
|
|
function ensureDropdown() {
|
|
if (dropdown) return dropdown;
|
|
dropdown = document.createElement("div");
|
|
dropdown.className = "console-autocomplete-dropdown";
|
|
dropdown.setAttribute("role", "listbox");
|
|
dropdown.style.display = "none";
|
|
document.body.appendChild(dropdown);
|
|
return dropdown;
|
|
}
|
|
|
|
function position() {
|
|
if (!dropdown) return;
|
|
const rect = input.getBoundingClientRect();
|
|
dropdown.style.left = `${rect.left + window.scrollX}px`;
|
|
dropdown.style.top = `${rect.bottom + window.scrollY}px`;
|
|
dropdown.style.minWidth = `${rect.width}px`;
|
|
}
|
|
|
|
function close() {
|
|
if (!dropdown) return;
|
|
dropdown.style.display = "none";
|
|
items = [];
|
|
highlightIdx = 0;
|
|
}
|
|
|
|
function render() {
|
|
ensureDropdown();
|
|
if (items.length === 0) { close(); return; }
|
|
const rows = items.slice(0, MAX_RENDERED).map((e, i) => {
|
|
const selected = i === highlightIdx ? " aria-selected='true'" : "";
|
|
const kindClass = e.kind === "command" ? "kind-command" : "kind-cvar";
|
|
const desc = e.desc ? `<span class="console-autocomplete-desc">${escapeHtml(e.desc)}</span>` : "";
|
|
return `<div class="console-autocomplete-row ${kindClass}"${selected} role="option" data-idx="${i}"><span class="console-autocomplete-name">${escapeHtml(e.name)}</span>${desc}</div>`;
|
|
}).join("");
|
|
dropdown.innerHTML = rows;
|
|
dropdown.style.display = "block";
|
|
position();
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, c => ({
|
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
}[c]));
|
|
}
|
|
|
|
function acceptHighlighted() {
|
|
if (items.length === 0) return;
|
|
const chosen = items[highlightIdx];
|
|
const slice = firstTokenSlice(input.value, input.selectionStart || 0);
|
|
if (!slice) return;
|
|
const before = input.value.slice(0, slice.from);
|
|
const after = input.value.slice(slice.to);
|
|
input.value = before + chosen.name + after;
|
|
// Place caret at end of inserted name
|
|
const caret = before.length + chosen.name.length;
|
|
input.setSelectionRange(caret, caret);
|
|
recompute();
|
|
}
|
|
|
|
function recompute() {
|
|
if (!vocab) return;
|
|
const slice = firstTokenSlice(input.value, input.selectionStart || 0);
|
|
if (!slice || !slice.token) { close(); return; }
|
|
items = window.__rankVocab(slice.token, vocab);
|
|
if (items.length === 0) { close(); return; }
|
|
highlightIdx = 0;
|
|
render();
|
|
}
|
|
|
|
// --- Lazy vocab fetch on first focus ---
|
|
input.addEventListener("focus", async () => {
|
|
if (!vocab) {
|
|
vocab = await loadVocab();
|
|
}
|
|
}, { once: true });
|
|
|
|
input.addEventListener("input", () => {
|
|
if (!vocab) return; // fetch may not have resolved yet; next input will recompute
|
|
recompute();
|
|
});
|
|
|
|
input.addEventListener("keydown", (event) => {
|
|
if (event.key === "Tab" && !event.shiftKey) {
|
|
if (items.length > 0) {
|
|
event.preventDefault();
|
|
acceptHighlighted();
|
|
}
|
|
} else if (event.key === "Tab" && event.shiftKey) {
|
|
if (items.length > 0) {
|
|
event.preventDefault();
|
|
highlightIdx = (highlightIdx - 1 + Math.min(items.length, MAX_RENDERED))
|
|
% Math.min(items.length, MAX_RENDERED);
|
|
render();
|
|
}
|
|
} else if (event.key === "Escape") {
|
|
if (dropdown && dropdown.style.display !== "none") {
|
|
event.preventDefault();
|
|
close();
|
|
}
|
|
}
|
|
// ArrowUp/ArrowDown/Enter intentionally NOT handled here.
|
|
});
|
|
|
|
input.addEventListener("blur", () => {
|
|
// Delay close so a click on a dropdown row can fire first.
|
|
setTimeout(close, 100);
|
|
});
|
|
|
|
// Mouse click on a row → accept that row.
|
|
document.addEventListener("mousedown", (event) => {
|
|
if (!dropdown || dropdown.style.display === "none") return;
|
|
const row = event.target.closest(".console-autocomplete-row");
|
|
if (!row || !dropdown.contains(row)) return;
|
|
event.preventDefault();
|
|
highlightIdx = parseInt(row.dataset.idx, 10) || 0;
|
|
acceptHighlighted();
|
|
input.focus();
|
|
});
|
|
|
|
// HTMX form submission clears the input; close on submit.
|
|
form.addEventListener("htmx:beforeRequest", close);
|
|
|
|
// Reposition on resize/scroll while dropdown is open.
|
|
window.addEventListener("resize", () => { if (dropdown && dropdown.style.display !== "none") position(); });
|
|
window.addEventListener("scroll", () => { if (dropdown && dropdown.style.display !== "none") position(); }, true);
|
|
}
|
|
|
|
function bindAll(root) {
|
|
if (!root) return;
|
|
const scope = root.matches && root.matches("[data-console-form]") ? [root] : [];
|
|
if (root.querySelectorAll) {
|
|
root.querySelectorAll("[data-console-form]").forEach((el) => scope.push(el));
|
|
}
|
|
scope.forEach(bindConsoleAutocomplete);
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", () => bindAll(document));
|
|
document.addEventListener("htmx:load", (event) => bindAll(event.detail.elt));
|
|
```
|
|
|
|
- [ ] **Step 2: Commit (no template/CSS wire-up yet — module is not yet loaded)**
|
|
|
|
```bash
|
|
git add l4d2web/l4d2web/static/js/console-autocomplete.js
|
|
git commit -m "feat(console): add vanilla autocomplete dropdown module"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Add dropdown stylesheet
|
|
|
|
**Goal:** Provide minimal CSS so the dropdown is positioned, themed via existing CSS tokens, and visually consistent with the editor's autocomplete popup.
|
|
|
|
**Files:**
|
|
- Create: `l4d2web/l4d2web/static/css/console-autocomplete.css`
|
|
|
|
- [ ] **Step 1: Write the stylesheet**
|
|
|
|
Create `l4d2web/l4d2web/static/css/console-autocomplete.css`:
|
|
|
|
```css
|
|
/* Console autocomplete dropdown.
|
|
Positioned absolutely under the console input by JS; visuals match
|
|
the editor's tooltip styling (var(--cm-*) tokens defined in
|
|
tokens.css and editor.css). */
|
|
|
|
.console-autocomplete-dropdown {
|
|
position: absolute;
|
|
z-index: 1000;
|
|
max-height: calc(8 * 2.4rem);
|
|
overflow-y: auto;
|
|
background-color: var(--cm-bg, #1e1e1e);
|
|
color: var(--cm-fg, #e0e0e0);
|
|
border: 1px solid var(--border-strong, #444);
|
|
border-radius: 4px;
|
|
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
font-size: 13px;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
}
|
|
|
|
.console-autocomplete-row {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 0.75em;
|
|
padding: 0.3em 0.6em;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.console-autocomplete-row[aria-selected="true"] {
|
|
background-color: var(--cm-selection, #264f78);
|
|
}
|
|
|
|
.console-autocomplete-row:hover {
|
|
background-color: var(--cm-selection, #264f78);
|
|
}
|
|
|
|
.console-autocomplete-name {
|
|
font-weight: 600;
|
|
}
|
|
|
|
.console-autocomplete-row.kind-cvar .console-autocomplete-name {
|
|
color: var(--cm-keyword, #569cd6);
|
|
}
|
|
|
|
.console-autocomplete-row.kind-command .console-autocomplete-name {
|
|
color: var(--cm-string, #ce9178);
|
|
}
|
|
|
|
.console-autocomplete-desc {
|
|
color: var(--fg-muted, #888);
|
|
font-size: 0.9em;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
max-width: 40em;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add l4d2web/l4d2web/static/css/console-autocomplete.css
|
|
git commit -m "feat(console): add autocomplete dropdown stylesheet"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Wire up in `base.html`
|
|
|
|
**Goal:** Load the ranker bundle, the console-autocomplete script, and the stylesheet — placed alongside the existing `console-history.js` tag so loading order matches.
|
|
|
|
**Files:**
|
|
- Modify: `l4d2web/l4d2web/templates/base.html`
|
|
|
|
- [ ] **Step 1: Read the current head/body script section**
|
|
|
|
Open `l4d2web/l4d2web/templates/base.html` and find the line that currently loads `console-history.js`:
|
|
|
|
```html
|
|
<script defer src="{{ url_for('static', filename='js/console-history.js') }}"></script>
|
|
```
|
|
|
|
- [ ] **Step 2: Add the new tags directly after it**
|
|
|
|
Add immediately after the `console-history.js` script tag:
|
|
|
|
```html
|
|
<script defer src="{{ url_for('static', filename='vendor/vocab-rank.bundle.js') }}"></script>
|
|
<script defer src="{{ url_for('static', filename='js/console-autocomplete.js') }}"></script>
|
|
```
|
|
|
|
And add to the `<head>` section (alongside other `<link rel="stylesheet">` tags — search for existing ones in `base.html`):
|
|
|
|
```html
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/console-autocomplete.css') }}">
|
|
```
|
|
|
|
- [ ] **Step 3: Sanity-check the template renders without syntax errors**
|
|
|
|
```bash
|
|
cd l4d2web && python -c "from l4d2web.app import create_app; create_app(); print('ok')"
|
|
```
|
|
|
|
Expected: prints `ok` (Flask app boots; templates are valid Jinja).
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add l4d2web/l4d2web/templates/base.html
|
|
git commit -m "feat(console): wire up autocomplete bundle + stylesheet in base.html"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: End-to-end smoke test
|
|
|
|
**Goal:** Verify the full feature works in the browser against the dev server.
|
|
|
|
- [ ] **Step 1: Start the dev server**
|
|
|
|
```bash
|
|
cd l4d2web && python ../scripts/dev-server.py
|
|
```
|
|
|
|
Expected: server starts on `http://localhost:5000` (or whatever the script reports). `LEFT4ME_ROOT` is auto-set to `.tmp/dev-server` and seeded with demo content per memory.
|
|
|
|
- [ ] **Step 2: Run through the smoke-test checklist in a browser**
|
|
|
|
Open a server-detail page (one of the demo servers seeded by the dev server). Then verify each:
|
|
|
|
1. **Vocab fetch is lazy.** Open DevTools → Network → filter `srccfg-vocab`. Reload page. **Expected:** no request yet.
|
|
2. **Click into the console input.** **Expected:** one `srccfg-vocab.json` request fires.
|
|
3. **Type `sv_`.** **Expected:** dropdown appears showing cvars starting with `sv_`. Top row highlighted.
|
|
4. **Press Tab.** **Expected:** first token replaced with the highlighted suggestion (e.g. `sv_cheats`). Dropdown updates with matches for the new query.
|
|
5. **Press Shift+Tab.** **Expected:** highlight moves up; or wraps to bottom if at top.
|
|
6. **Press Esc.** **Expected:** dropdown closes. Input value unchanged.
|
|
7. **Type a space then `god`.** **Expected:** dropdown stays hidden (we're past the first token).
|
|
8. **Press ArrowUp.** **Expected:** history recall works — input is replaced with a previously submitted command. No interference from autocomplete.
|
|
9. **Clear the input. Type `sv_che`.** Verify `sv_cheats` is highlighted in the dropdown. **Press Enter.** **Expected:** the server console receives `sv_che` (the typed text), not `sv_cheats`. Confirm in the console transcript.
|
|
10. **Refocus the input.** **Expected:** no second `srccfg-vocab.json` request (cached in module-scope promise).
|
|
11. **Click on a dropdown row with the mouse.** **Expected:** that row's command is inserted into the input.
|
|
12. **Editor regression check.** Navigate to a `.cfg` file in the editor (files view). Type `sv_`. **Expected:** editor's autocomplete still works exactly as before.
|
|
|
|
If all 12 pass, the feature is complete.
|
|
|
|
- [ ] **Step 3: Stop dev server (Ctrl+C) and confirm final commit state**
|
|
|
|
```bash
|
|
git log --oneline -10
|
|
git status
|
|
```
|
|
|
|
Expected: 6 new commits ahead of the pre-feature state; working tree clean.
|
|
|
|
---
|
|
|
|
## Verification Summary
|
|
|
|
- **Unit tests:** `cd l4d2web/scripts/editor-src && node --test vocab-rank.test.js` — 10 passing tests for the ranker.
|
|
- **Manual editor regression:** Editor autocomplete still works on `.cfg` files.
|
|
- **Manual console smoke test:** 12-point checklist in Task 7 Step 2.
|
|
- **No new runtime JS dependencies** added (vocab-rank.test.js uses only `node:test` + `node:assert/strict`, which are built into Node ≥ 18).
|
|
|
|
## What's Explicitly Out of Scope
|
|
|
|
- Argument value completion (player names, map names) — would require runtime data, not in `srccfg-vocab.json`.
|
|
- Fuzzy / typo-tolerant matching.
|
|
- Replacing CodeMirror's editor dropdown with a custom widget.
|
|
- Cross-browser e2e automation (no Playwright/Cypress in the codebase; not adding one as part of this work).
|