diff --git a/docs/superpowers/specs/2026-05-20-server-console-log-autoscroll-design.md b/docs/superpowers/specs/2026-05-20-server-console-log-autoscroll-design.md new file mode 100644 index 0000000..005e45a --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-server-console-log-autoscroll-design.md @@ -0,0 +1,179 @@ +# Server detail — console + log autoscroll and inline-history cap + +**Date:** 2026-05-20 +**Status:** Design approved (pending user review of this spec) +**Touches:** `server_detail` page only (inline tab strip + modals) + +## Problem + +Three usability issues on the server detail page's inspection strip: + +1. **Console transcript doesn't land at the bottom when the Console tab is first opened.** The pane is `hidden` at page load, so the existing `scrollConsolesToBottom` call on `DOMContentLoaded` runs against a `display:none` element and effectively sets `scrollTop` to 0. When the user clicks the Console tab they see the *top* of 50 entries. +2. **Console transcript shows too many past commands inline.** The route loads 50 `CommandHistory` rows; both inline pane and modal render all of them. 50 is right for the modal but heavy for an 18rem inline pane. +3. **Submitting a command doesn't scroll the transcript to the new line.** `htmx:load` fires with `event.detail.elt` set to the newly-inserted `
` may not be at the bottom even though `sse.js` scrolls on each append, because per-append scroll while the pane is `display:none` is a no-op in practice. + +## Goal + +When the user opens, switches to, or appends to any auto-pinned scroll region (Log stream or Console transcript) on the server detail page, the region is scrolled to its bottom. + +Cap the inline Console pane at 20 entries; keep the modal at 50. + +## Non-goals + +- No change to the SSE per-append scroll in `sse.js`. It already handles its own case. +- No change to the visual height of `.tab-pane` (stays at 18rem). +- No new endpoint. The existing `/servers//console/history` paging API is unaffected. +- No change to the recent-players or files tabs. + +## Approach (selected: B — single generic attribute) + +The Log stream and Console transcript are semantically the same: a scroll-locked terminal that must stay pinned to the bottom on (a) initial render, (b) tab activation, (c) content append. Treat them with one mechanism: an opt-in `data-autoscroll` attribute and a single helper that scrolls any such element. + +Rejected: hardcoding `.log-stream` and `.console-transcript` selectors inside `tabs.js` ("Approach A"). That couples the tab subsystem to two unrelated CSS classes and means any future scroll-pinned region needs a third entry. + +## Architecture + +``` +┌──────────────────────────────────────────────────────────┐ +│ page_routes.py │ +│ loads console_history (50) │ +│ passes: │ +│ console_history → modal Console pane │ +│ console_history_overview → inline Console pane (20) │ +└──────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ server_detail.html │ +│ inline transcript: data-autoscroll, loops overview │ +│ modal transcript: data-autoscroll, loops full │ +│ both log-stream s: data-autoscroll added │ +└──────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ console-history.js (or split into autoscroll.js) │ +│ scrollAutoscrollTargets(root): │ +│ - if root matches [data-autoscroll]: scroll it │ +│ - else scroll any descendants with [data-autoscroll] │ +│ - else walk up; if ancestor [data-autoscroll]: scroll │ +│ Exposed on window for cross-module use. │ +│ Wired to htmx:load. │ +└──────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ tabs.js │ +│ after activateTab(): call window.scrollAutoscrollTargets│ +│ on the newly-active tabpanel │ +└──────────────────────────────────────────────────────────┘ +``` + +## Detailed design + +### 1. Route — `l4d2web/l4d2web/routes/page_routes.py` (~L318-345) + +Add: + +```python +console_history_overview = console_history[-20:] +``` + +Pass it to `render_template`: + +```python +return render_template( + "server_detail.html", + ... + console_history=console_history, + console_history_overview=console_history_overview, + **ctx, +) +``` + +`console_history` is already chronological (oldest → newest after the `reversed(...)`), so `[-20:]` returns the 20 newest in chronological order — correct for top-down rendering. + +### 2. Template — `l4d2web/l4d2web/templates/server_detail.html` + +- Inline Console pane (currently iterates `console_history`): switch to `console_history_overview`. +- Modal Console pane: unchanged, still iterates `console_history`. +- Both `` elements (inline and modal): add `data-autoscroll`. +- Both `.console-transcript` divs already have `data-autoscroll`; no change. + +### 3. JS — autoscroll helper + +Rename `scrollConsolesToBottom` to `scrollAutoscrollTargets` in `console-history.js` (or extract it into a tiny new `autoscroll.js`; either is fine — pick whichever keeps the diff small). New behavior: + +```js +function scrollAutoscrollTargets(root) { + if (!root) return; + const targets = []; + // Case 1: root itself opts in. + if (root.matches && root.matches("[data-autoscroll]")) { + targets.push(root); + } + // Case 2: descendants opt in. + if (root.querySelectorAll) { + root.querySelectorAll("[data-autoscroll]").forEach((el) => targets.push(el)); + } + // Case 3: neither — try walking up. This handles htmx:load firing with + // the inserted child as the root after hx-swap="beforeend". + if (targets.length === 0 && root.closest) { + const up = root.closest("[data-autoscroll]"); + if (up) targets.push(up); + } + targets.forEach((el) => { + el.scrollTop = el.scrollHeight; + }); +} + +window.scrollAutoscrollTargets = scrollAutoscrollTargets; +``` + +The `htmx:load` listener already calls the helper; it just needs the renamed function. + +### 4. JS — `tabs.js` + +In `activateTab(strip, name)`, after the loop that toggles `hidden`, add: + +```js +const activePane = strip.querySelector('[role="tabpanel"]:not([hidden])'); +if (activePane && window.scrollAutoscrollTargets) { + window.scrollAutoscrollTargets(activePane); +} +``` + +This runs on both initial activation (`initStrips`) and click-driven activation. + +### 5. CSS + +No changes. The 18rem fixed-height `.tab-pane` and existing `.console-transcript` / `.log-stream` styles already provide the scroll container. + +## Tests + +**Unit / template render** (`l4d2web/tests/test_servers.py`): + +- `test_server_detail_inline_console_pane_caps_at_20_lines` — seed 30 `CommandHistory` rows, render page, assert the inline `console-transcript-inline-` container has exactly 20 `.console-line` children and the modal container has 30. + +**e2e** (`l4d2web/tests/e2e/test_server_detail.py`): + +- `test_console_tab_scrolled_to_bottom_on_first_activation` — seed > 20 history rows so the transcript overflows, open page, click Console tab, assert `await transcript.evaluate("(el) => Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < 2")` returns true. +- `test_console_tab_scrolled_to_bottom_after_command_submit` — open Console, send a command via the input, after the swap settles assert the same predicate. +- `test_log_tab_scrolled_to_bottom_on_reactivation` — switch to Console, then back to Log (skipped if no log lines flowed; otherwise assert the predicate against the log-stream). + +The "scrolled to bottom" predicate is a 2-pixel tolerance to absorb subpixel rounding across browsers. + +## Risk and edge cases + +- **Empty transcript / empty log:** `scrollHeight === clientHeight`; setting `scrollTop = scrollHeight` is a no-op. Safe. +- **Modal Console transcript not yet open:** On page load the modal is `display:none` (via `