left4me/docs/superpowers/specs/2026-05-20-server-console-log-autoscroll-design.md
mwiegand 2415885d30
docs(server-detail): spec — console/log autoscroll + inline-history cap
Three usability fixes for the inspection strip on server_detail:
1. Pin transcripts/logs to bottom on tab activation.
2. Cap inline Console to 20 entries; modal keeps 50.
3. Pin to bottom after a console-line is appended via HTMX.

Approach B: single data-autoscroll opt-in attribute + one helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 22:29:51 +02:00

11 KiB

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 <div class="console-line">. 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 <pre class="log-stream"> 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/<id>/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 <pre>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:

console_history_overview = console_history[-20:]

Pass it to render_template:

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 <pre class="log-stream"> 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:

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:

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-<id> 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 <dialog> not yet showModal()-ed). Setting scrollTop on it does nothing useful — but the modal <dialog> 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 <script> block order. As long as console-history.js is included before tabs.js, the function is defined when needed. Plan will verify and reorder if necessary.
  • 20 is a hard-coded magic number: Acceptable for a UI cap; deliberately not made configurable. If a future change wants it tunable, lift to a Flask config constant in one place.

Out of scope (for follow-up if desired)

  • A "Pause autoscroll if user scrolled up" affordance (common in chat UIs). Not requested; current behavior is "always pin to bottom".
  • A "Clear console history" button. Not requested.
  • Modal-Console initial scroll-to-bottom on dialog.showModal(). Covered conditionally by the modal-opened hook above; if that wiring proves brittle, falls out of scope into a separate task.