# 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 `