The timeline inserts a new row at the top the instant it arrives: static/live.js calls list.prepend(...), and on a busy hub that means the thing you were reading slides down the screen mid-sentence, repeatedly. AGENTS.md asks for no layout shift, and this is the one place the app creates it on purpose.
Done looks like
[ ] A new event that arrives while the page is scrolled to the top is inserted as it is today — that is the case the live timeline exists for, and it must stay instant.
[ ] A new event that arrives while the reader is anywhere else is held: a small, fixed "3 new events" control appears (a button, 44px, keyboard-reachable, announced with aria-live="polite"), and pressing it inserts the held rows and returns the view to the top. Nothing moves until the reader asks.
[ ] The held count is exact and bounded: it counts what matches the page's current filter, caps at a stated number ("99+") and resets to zero when the rows are inserted or the page is reloaded.
[ ] Rows still arrive in order after being held, no row is inserted twice (the data-id check stays), and a reconnect replaying ?since= while the reader is scrolled does not flush the queue behind their back.
[ ] No layout shift when the control appears: it is positioned so the timeline does not reflow, and at 360px it does not cover a row's controls.
[ ] static/live.js stays small and dependency-free, and the whole behaviour degrades to today's if JavaScript is off (the server-rendered page is unchanged).
[ ] Tests: the queueing logic is a pure function given a scroll position, a filter and a list of events, tested in static/live.test.ts — the DOM half is verified by hand in the preview and the pull says what was checked, at 360px and at desktop width.
Risks: "scrolled to the top" needs a tolerance, not an equality — a few pixels of overscroll must still count as the top, and the number chosen should be stated. Holding rows means the page and the database disagree for a moment: the control's count is the honest way to say so, and a reload must always show the truth. Do not reach for a framework or a virtual list; this is a handful of lines against a list that #41 will keep short.
Suggested: Scout — instant feedback is its brief, and this is the shape of instant that does not fight the reader.
The timeline inserts a new row at the top the instant it arrives:
static/live.jscallslist.prepend(...), and on a busy hub that means the thing you were reading slides down the screen mid-sentence, repeatedly.AGENTS.mdasks for no layout shift, and this is the one place the app creates it on purpose.Done looks like
aria-live="polite"), and pressing it inserts the held rows and returns the view to the top. Nothing moves until the reader asks.data-idcheck stays), and a reconnect replaying?since=while the reader is scrolled does not flush the queue behind their back.static/live.jsstays small and dependency-free, and the whole behaviour degrades to today's if JavaScript is off (the server-rendered page is unchanged).static/live.test.ts— the DOM half is verified by hand in the preview and the pull says what was checked, at 360px and at desktop width.Files:
static/live.js,static/live.test.ts,static/app.cssRisks: "scrolled to the top" needs a tolerance, not an equality — a few pixels of overscroll must still count as the top, and the number chosen should be stated. Holding rows means the page and the database disagree for a moment: the control's count is the honest way to say so, and a reload must always show the truth. Do not reach for a framework or a virtual list; this is a handful of lines against a list that #41 will keep short.
Suggested: Scout — instant feedback is its brief, and this is the shape of instant that does not fight the reader.