# 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 `
`. The current `scrollConsolesToBottom` looks at that element and its descendants — neither matches `[data-autoscroll]`, so nothing is scrolled. (The transcript container is an *ancestor* of the inserted line.) The Log tab has the same latent bug as #1: if the user switches away from Log and back, the `
` 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 `` not yet `showModal()`-ed). Setting scrollTop on it does nothing useful — but the modal `` Console transcript also has `data-autoscroll`, and HTMX/dialog open events would need a separate hook to pin it on first display. **Scope decision:** modal open already triggers re-pin via the existing `htmx:load` flow when the modal's content is HTMX-loaded; for the Console modal, transcript content is server-rendered into the dialog at page load, so we add a one-line `modal:opened` listener (consistent with the existing `recent-players-modal` pattern that uses `modal:opened` triggers). If `modal:opened` doesn't exist as an event in `modals.js`, fall back to running the helper inside the modal's open hook. **Implementation plan will confirm which.**
- **`window.scrollAutoscrollTargets` undefined at the time `tabs.js` activates a tab:** `tabs.js` and `console-history.js` are both loaded via `base.html` in the same `