From 2415885d30290241e8e5290d325efd2229e63eae Mon Sep 17 00:00:00 2001 From: mwiegand Date: Wed, 20 May 2026 22:29:51 +0200 Subject: [PATCH] =?UTF-8?q?docs(server-detail):=20spec=20=E2=80=94=20conso?= =?UTF-8?q?le/log=20autoscroll=20+=20inline-history=20cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...20-server-console-log-autoscroll-design.md | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-20-server-console-log-autoscroll-design.md 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 `
`. 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 `