diff --git a/.agent/PLAN.md b/.agent/PLAN.md
index 070c854..6629e8c 100644
--- a/.agent/PLAN.md
+++ b/.agent/PLAN.md
@@ -6,7 +6,8 @@
> **Revisions (2026-08-21, owner permission):** A7/A8/A9 revised (multi-format
> ingestion, hybrid FTS+vector retrieval, re-tuned honesty gate); dark tech
> theme (Phase 08); clickable document viewer (Phase 10); thinking display
-> (Phase 17, owner permission 2026-08-23). See roadmap §12.
+> (Phase 17, owner permission 2026-08-23); follow-the-bottom scroll
+> (Phase 18, owner choice 2026-08-23). See roadmap §12.
---
@@ -308,9 +309,13 @@ Rules:
| **Error** | Red banner (`role="alert"`) with retry hint; button re-enabled. |
| **KB offline** | Amber banner at top of chat ("start Postgres…"); chat disabled with explanation. |
| **Guard** | 120s client-side timeout → error state (a button can never sit "stuck" forever). |
+| **Scroll (follow-the-bottom, phase 18)** | The page auto-scrolls only while the user is pinned to the bottom (≤200px band, `NEAR_BOTTOM_PX` — the composer zone; submitting reveals the user's message through the same gate, which holds in real use); scrolling up holds the viewport for the rest of the turn (thinking and answer alike); restore lands one-shot on the latest message. |
> The **Thinking (model reasoning)** row is a phase-17 addition (owner
> permission 2026-08-23) — see the §4 SSE revision.
+>
+> The **Scroll** row is a phase-18 addition (owner choice 2026-08-23 —
+> option 1: follow-the-bottom, no "↓ new content" pill).
### 7.5 Component inventory (ids used by tests)
`#messages` (stream), `#empty-state`, `#suggestions`, `.suggestion-chip`,
@@ -410,9 +415,14 @@ ranks live hybrid results for a question (retrieval tuning).
| 09 | `09_story_retrieval_quality.md` | `retrieval-quality.md` | `tests/e2e/test_retrieval_quality.py` |
| 10 | `10_story_document_viewer.md` | `document-viewer.md` | `tests/e2e/test_document_viewer.py` |
| 17 | `17_thinking_display.md` | `thinking-display.md` | `tests/e2e/test_thinking_display.py` |
+| 18 | `18_follow_bottom_scroll.md` | `follow-bottom-scroll.md` | `tests/e2e/test_follow_bottom_scroll.py` |
> Row 17 (thinking display) added 2026-08-23 with owner permission — the
> A15 SSE extension recorded in §4.
+>
+> Row 18 (follow-the-bottom scroll) added 2026-08-23 with owner choice —
+> option 1: follow-the-bottom, no "↓ new content" pill (UI-behavior-only
+> change; no anchor revised).
Completion = unit+integration green, coverage >90%, story E2E green in
isolation, UI verification passed, **one `--no-gpg-sign` commit**.
diff --git a/frontend/assets/app.js b/frontend/assets/app.js
index e7d45ab..84a4075 100644
--- a/frontend/assets/app.js
+++ b/frontend/assets/app.js
@@ -50,6 +50,16 @@
* announced through a polite live region (#steering-announcer), and the
* panel + count badge update on every change.
*
+ * Scroll (phase 18, owner choice 2026-08-23): the page auto-scrolls only
+ * while the user is pinned to the bottom. NEAR_BOTTOM_PX (200px) covers
+ * the composer zone — the textarea auto-grows to 192px plus the button
+ * row — so "the composer is in view" counts as pinned: submitting from
+ * the composer reveals your own message, and the answer follows token by
+ * token while you stay pinned. Once you scroll up to read earlier
+ * content, nothing drags the viewport back down for the rest of the turn
+ * (thinking or answer). scrollReveal(wrap) is the single scroll gate;
+ * `force` is reserved for the one-shot phase-14 restore landing.
+ *
* All DOM ids match frontend/index.html.
*/
@@ -99,6 +109,28 @@ const reducedMotion =
typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
const SCROLL = reducedMotion ? "auto" : "smooth";
+/* Follow-the-bottom scroll contract (phase 18, owner choice
+ * 2026-08-23): the page auto-scrolls only while the user is pinned
+ * at the bottom — the 200px band covers the composer zone (the
+ * textarea auto-grows to 192px + the button row), i.e. "the
+ * composer is in view". Exported so the band is unit-pinned (same
+ * pattern as TURN_TIMEOUT_MS). */
+export const NEAR_BOTTOM_PX = 200;
+
+function isNearBottom() {
+ const bottom =
+ document.documentElement.scrollHeight - window.scrollY - window.innerHeight;
+ return bottom <= NEAR_BOTTOM_PX;
+}
+
+/* The ONE scroll call site in this file. `force` is used only by
+ * the phase-14 restore landing (one-shot, load-time). */
+function scrollReveal(wrap, behavior = SCROLL, force = false) {
+ if (force || isNearBottom()) {
+ wrap.scrollIntoView({ behavior, block: "end" });
+ }
+}
+
/* ---------- document viewer link (phase 10; phase 13 adds `back`) ----------
* Every cited document opens in the viewer, in a NEW tab. All query
* values are percent-encoded: real paths contain slashes and sometimes
@@ -327,8 +359,11 @@ const BRAIN_AVATAR =
const USER_AVATAR =
'';
-/* ---------- messages ---------- */
-function addMessage(who, html, scrollBehavior = SCROLL) {
+/* ---------- messages ----------
+ * Scroll is conditional (phase 18): addMessage reveals through
+ * scrollReveal — only when the user is pinned to the bottom, or when
+ * forced (the one-shot phase-14 restore landing). */
+function addMessage(who, html, scrollBehavior = SCROLL, force = false) {
if (emptyState) emptyState.hidden = true;
const wrap = document.createElement("div");
wrap.className = `msg ${who}`;
@@ -338,7 +373,7 @@ function addMessage(who, html, scrollBehavior = SCROLL) {
${html}
`;
messagesEl.appendChild(wrap);
- wrap.scrollIntoView({ behavior: scrollBehavior, block: "end" });
+ scrollReveal(wrap, scrollBehavior, force);
return wrap;
}
@@ -356,7 +391,7 @@ function addTyping() {
`;
messagesEl.appendChild(wrap);
- wrap.scrollIntoView({ behavior: SCROLL, block: "end" });
+ scrollReveal(wrap);
}
function removeTyping() {
@@ -670,11 +705,14 @@ export function clearStoredConversation() {
}
function renderStoredMessage(m) {
+ // Phase 18: the restore landing is the only `force`d scroll — one-shot,
+ // non-smooth, so a restored conversation lands on its latest message
+ // (phase-14 behavior preserved) without smooth-scrolling through it.
if (m.who === "user") {
- addMessage("user", renderMarkdown(m.text), "auto");
+ addMessage("user", renderMarkdown(m.text), "auto", true);
return;
}
- const wrap = addMessage("brain", renderMarkdown(m.text), "auto");
+ const wrap = addMessage("brain", renderMarkdown(m.text), "auto", true);
if (m.thinking) {
// Phase 17: restore the thinking block COLLAPSED above the bubble.
const block = ensureThinkingBlock(wrap);
@@ -855,7 +893,7 @@ async function handleSend(e) {
textEl.innerHTML = renderMarkdown(thinkingAcc); // escape-first, XSS-safe
if (block.open) {
textEl.scrollTop = textEl.scrollHeight; // pin the stream to the bottom
- wrap.scrollIntoView({ behavior: SCROLL, block: "end" });
+ scrollReveal(wrap); // page follows only while pinned (phase 18)
}
} else if (ev.type === "delta") {
acc += ev.text || "";
@@ -863,7 +901,7 @@ async function handleSend(e) {
if (!wrap) wrap = addMessage("brain", ""); // first token: live bubble in
closeThinkingBlock(wrap); // auto-collapse; idempotent, never reopens
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc);
- wrap.scrollIntoView({ behavior: SCROLL, block: "end" });
+ scrollReveal(wrap); // page follows only while pinned (phase 18)
} else if (ev.type === "done") {
sawDone = true;
closeThinkingBlock(wrap); // the turn is over: settle the block closed
@@ -928,7 +966,9 @@ async function handleSend(e) {
stopThinkingClock();
try { res?.body?.cancel(); } catch { /* stream already closed */ }
if (uiState !== UI_STATE.idle) setUiState(UI_STATE.idle);
- input.focus();
+ // Phase 18: focus back for the next question, but never move the
+ // viewport — a user reading earlier content stays where they are.
+ input.focus({ preventScroll: true });
}
}
diff --git a/tests/e2e/test_follow_bottom_scroll.py b/tests/e2e/test_follow_bottom_scroll.py
new file mode 100644
index 0000000..a9b691c
--- /dev/null
+++ b/tests/e2e/test_follow_bottom_scroll.py
@@ -0,0 +1,355 @@
+"""Phase 18 E2E (Playwright, mock-only): the chat follows the bottom.
+
+Story: ``.agent/user_stories/follow-bottom-scroll.md``
+Run in isolation (DB must be up: ``podman compose up -d db``):
+
+ uv run pytest tests/e2e/test_follow_bottom_scroll.py -v --no-cov
+
+The follow-the-bottom contract (owner choice 2026-08-23, option 1 — no
+"↓ new content" pill): the page auto-scrolls *only while the user is
+already pinned at the bottom*; submitting a question reveals the user's
+own message; once the user scrolls up, nothing auto-scrolls for the rest
+of the turn (thinking or answer); a restored conversation still lands on
+the latest message.
+
+MOCK-ONLY suite: the scenarios key off the deterministic mock's
+``write a long answer`` trigger (~900 words ≈ 8s of streaming — a wide,
+reliable window to scroll away in) and, for scenario 4, the phase-17
+``think out loud`` trigger (both fire independently). ``E2E_REAL_LLM=1``
+would make the scroll-away windows unpredictable, so it is not supported
+here.
+
+Measurement convention: the scroller is the DOCUMENT — there is no inner
+scroll container (``body`` is ``min-height: 100dvh``; the page scrolls on
+the window). Scroll position is read via ``page.evaluate`` as
+``{ y: window.scrollY, sh: document.documentElement.scrollHeight,
+ch: window.innerHeight }``; "near bottom" = ``sh - y - ch <= 200``
+(mirrors the frontend's ``NEAR_BOTTOM_PX``); scrolling to the top is
+``page.evaluate("() => window.scrollTo(0, 0)")``.
+
+Real-user flow: the user submits from the composer — i.e. pinned at the
+bottom (a normal ``fill`` + ``Enter``/click) — and only *after* the
+stream starts do they scroll up to read earlier messages. The no-yank
+scenarios follow exactly that sequence, so no off-screen input
+manipulation is needed (and Playwright's own click/fill auto-scroll
+never fires, because the composer is already in view).
+
+Determinism note: the mock paces every SSE frame at 0.02s, so the long
+answer streams for several seconds — "mid-stream" assertions land
+comfortably inside the window on headless Chromium. Every "held still"
+assertion compares against the exact ``scrollTo(0, 0)`` position
+(tolerance 5px for rounding).
+
+Test → story mapping (Playwright Mapping Rule):
+1. ``test_submit_reveals_new_message``
+2. ``test_stream_follows_while_pinned_at_bottom``
+3. ``test_no_yank_while_scrolled_up_during_answer_stream``
+4. ``test_no_yank_while_scrolled_up_during_thinking``
+5. ``test_restore_lands_on_latest_message``
+"""
+from __future__ import annotations
+
+import asyncio
+import time
+from collections.abc import Iterator
+from pathlib import Path
+from threading import Thread
+from typing import Any
+
+import pytest
+from playwright.sync_api import Page, expect
+from sqlalchemy import text
+
+from app.config import Settings
+from app.db import SessionLocal
+from app.rag.importer import ImportSummary, import_sources
+from app.rag.llm import LLMClient
+
+REPO = Path(__file__).resolve().parents[2]
+FIXTURES = REPO / "tests" / "fixtures" / "docs"
+
+#: Mirror of app.js's exported ``NEAR_BOTTOM_PX`` (the 200px composer-zone
+#: band that counts as "pinned to the bottom").
+NEAR_BOTTOM_PX = 200
+
+#: Mock long-answer trigger (~900 words ≈ 8s of streaming at the mock's
+#: 0.02s/frame pace) — the wide, deterministic window to scroll away in.
+LONG_QUESTION = "write a long answer about my kubernetes cluster"
+#: Phase-17 thinking prefix + the long-answer trigger: both mock triggers
+#: fire independently (a ~1.3s reasoning stream, then the long answer).
+THINK_LONG_QUESTION = "think out loud — write a long answer about my kubernetes cluster"
+#: The mock long answer's unique final line (mock_llm.LONG_ANSWER_END) —
+#: proves the whole stream landed even while the viewport was at the top.
+LONG_ANSWER_END = "LONG-ANSWER-END"
+#: Line fragment the mock's deterministic scratchpad carries
+#: (mock_llm.compose_thinking) — same key phase 17's suite uses.
+THINKING_FRAGMENT = "Step 2: Check my notes"
+#: Tolerance for "the viewport held still at the top" (rounding).
+HOLD_TOLERANCE_PX = 5
+
+
+async def _import_fixtures(mock_port: int) -> ImportSummary:
+ kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
+ settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
+ return await import_sources([FIXTURES], LLMClient(settings))
+
+
+def _run_in_thread(coro: Any) -> Any:
+ """Run a coroutine on a worker thread.
+
+ Playwright's sync API keeps an asyncio loop running on the test thread,
+ so ``asyncio.run`` cannot be called directly from a test body.
+ """
+ box: dict[str, Any] = {}
+
+ def runner() -> None:
+ try:
+ box["value"] = asyncio.run(coro)
+ except BaseException as e: # noqa: BLE001 — re-raised on the test thread
+ box["error"] = e
+
+ t = Thread(target=runner)
+ t.start()
+ t.join()
+ if "error" in box:
+ raise box["error"]
+ return box["value"]
+
+
+def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
+ """Truncate the KB (and query log), then optionally re-import fixtures."""
+ with SessionLocal() as db:
+ db.execute(text("TRUNCATE chunks, documents, query_log"))
+ db.commit()
+ if not seed:
+ return None
+ return _run_in_thread(_import_fixtures(mock_port))
+
+
+@pytest.fixture()
+def seeded_kb(mock_llm: int, db_ready: None) -> Iterator[None]:
+ """A fresh KB seeded from ``tests/fixtures/docs`` (8 docs, A9 formats),
+ truncated again on teardown. ``db_ready`` (conftest) skips with clear
+ instructions when Postgres is down."""
+ summary = _reset_db(mock_llm, seed=True)
+ assert summary is not None and summary.added == 8
+ yield
+ _reset_db(mock_llm, seed=False)
+
+
+# ---------------------------------------------------------------------------
+# Measurement + flow helpers
+# ---------------------------------------------------------------------------
+
+
+def scroll_state(page: Page) -> dict[str, float]:
+ """The document scroller's state (there is no inner scroll container)."""
+ return page.evaluate(
+ "() => ({ y: window.scrollY, "
+ "sh: document.documentElement.scrollHeight, "
+ "ch: window.innerHeight })"
+ )
+
+
+def near_bottom(state: dict[str, float]) -> bool:
+ """Mirror of app.js's ``isNearBottom`` — the NEAR_BOTTOM_PX band."""
+ return state["sh"] - state["y"] - state["ch"] <= NEAR_BOTTOM_PX
+
+
+def held_at_top(page: Page) -> bool:
+ """The viewport has not moved from ``window.scrollTo(0, 0)`` (±5px)."""
+ return scroll_state(page)["y"] <= HOLD_TOLERANCE_PX
+
+
+def wait_settled(page: Page) -> None:
+ """The turn is over: the never-stale contract re-enabled the button."""
+ expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
+ expect(page.locator("#send-label")).to_have_text("Send")
+
+
+def submit(page: Page, question: str) -> None:
+ """Submit from the composer — the real-user flow (pinned at the
+ bottom, so Playwright's click/fill auto-scroll never kicks in)."""
+ page.fill("#message-input", question)
+ page.click("#send-btn")
+ expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
+
+
+def brain_bubble_longer_than(n: int) -> str:
+ """JS predicate: the LAST brain bubble's rendered text is > n chars
+ (i.e. that far into the stream)."""
+ return (
+ "() => { const els = document.querySelectorAll('.msg.brain .bubble');"
+ f" const el = els[els.length - 1]; return !!el && el.innerText.length > {n}; }}"
+ )
+
+
+def brain_message_in_view(page: Page) -> bool:
+ """The last brain message intersects the viewport vertically. Partial
+ visibility counts: a long answer is taller than the window, and the
+ contract is that it is revealed (its lower edge in view), not that it
+ fits."""
+ box = page.locator(".msg.brain").last.bounding_box()
+ if box is None:
+ return False
+ ch = scroll_state(page)["ch"]
+ return box["y"] < ch and box["y"] + box["height"] > 0
+
+
+# ---------------------------------------------------------------------------
+# 1. Submit: the user's message and the answer reveal into view
+# ---------------------------------------------------------------------------
+
+
+def test_submit_reveals_new_message(page: Page, app_url: str, seeded_kb: None) -> None:
+ page.set_default_timeout(30_000)
+ page.goto(app_url)
+ # A fresh chat page starts pinned at the bottom (short conversation —
+ # the composer, i.e. the user, sits in the band).
+ assert near_bottom(scroll_state(page))
+
+ submit(page, LONG_QUESTION)
+ wait_settled(page)
+
+ # The last brain message is inside the viewport ...
+ assert brain_message_in_view(page), (
+ "the answer must be revealed — the last brain message is not in view"
+ )
+ # ... and the page is still pinned at the bottom.
+ assert near_bottom(scroll_state(page))
+
+
+# ---------------------------------------------------------------------------
+# 2. Follow: while pinned, the page keeps up with the stream
+# ---------------------------------------------------------------------------
+
+
+def test_stream_follows_while_pinned_at_bottom(page: Page, app_url: str, seeded_kb: None) -> None:
+ page.set_default_timeout(30_000)
+ page.goto(app_url)
+ submit(page, LONG_QUESTION)
+
+ # ~2s into the stream: the answer bubble already carries >200 chars
+ # (the mock paces frames at 0.02s).
+ page.wait_for_function(brain_bubble_longer_than(200), timeout=30_000)
+ # Let the smooth follow scroll settle before measuring.
+ time.sleep(0.3)
+ # The follow behavior is alive — not accidentally removed.
+ assert near_bottom(scroll_state(page)), (
+ "the page must follow the stream while the user is pinned at the bottom"
+ )
+
+ wait_settled(page)
+ assert near_bottom(scroll_state(page))
+
+
+# ---------------------------------------------------------------------------
+# 3. No yank: scrolled up mid-ANSWER — the viewport holds for the rest
+# of the turn (the answer finishes off-screen below, by design)
+# ---------------------------------------------------------------------------
+
+
+def test_no_yank_while_scrolled_up_during_answer_stream(
+ page: Page, app_url: str, seeded_kb: None
+) -> None:
+ page.set_default_timeout(30_000)
+ page.goto(app_url)
+
+ # Turn 1 (settled) makes the document overflow the 800px viewport.
+ submit(page, LONG_QUESTION)
+ wait_settled(page)
+ state = scroll_state(page)
+ assert state["sh"] > state["ch"], "a long answer must make the document scrollable"
+ assert near_bottom(state), "follow was active: the settled turn ends pinned"
+
+ # Turn 2: submit from the composer (pinned — normal flow), then let
+ # the new answer stream a bit.
+ submit(page, LONG_QUESTION)
+ page.wait_for_function(brain_bubble_longer_than(200), timeout=30_000)
+
+ # The user goes up to read while the stream is running.
+ page.evaluate("() => window.scrollTo(0, 0)")
+ # The stream kept running at the top ...
+ page.wait_for_function(brain_bubble_longer_than(600), timeout=30_000)
+ assert held_at_top(page), "the viewport must hold still while scrolled up"
+
+ # ... and nothing scrolls for the rest of the turn — the answer
+ # finishes off-screen below, by design.
+ wait_settled(page)
+ assert held_at_top(page)
+
+
+# ---------------------------------------------------------------------------
+# 4. No yank: scrolled up during THINKING — the whole reasoning stream
+# plus the answer's start happen at the top
+# ---------------------------------------------------------------------------
+
+
+def test_no_yank_while_scrolled_up_during_thinking(
+ page: Page, app_url: str, seeded_kb: None
+) -> None:
+ page.set_default_timeout(30_000)
+ page.goto(app_url)
+
+ # One settled turn first, so the document overflows (scrollable).
+ submit(page, LONG_QUESTION)
+ wait_settled(page)
+
+ # The thinking turn: submit pinned (normal flow) ...
+ submit(page, THINK_LONG_QUESTION)
+ details = page.locator(".msg.brain").last.locator("details.thinking")
+ details.wait_for(state="attached", timeout=10_000)
+ # ... and, while the reasoning stream is still open (phase-17
+ # behavior: created open, ~1.3s before the first answer token) ...
+ expect(details).to_have_attribute("open", "")
+ # ... the user goes up to read.
+ page.evaluate("() => window.scrollTo(0, 0)")
+
+ # The whole thinking stream plus the answer's start happen at the top.
+ bubble = page.locator(".msg.brain").last.locator(".bubble")
+ expect(bubble).not_to_have_text("", timeout=30_000)
+ assert held_at_top(page), "the viewport must hold still during thinking"
+
+ # Settled: still at the top, and everything landed (off-screen,
+ # which is the point of the story).
+ wait_settled(page)
+ assert held_at_top(page)
+ expect(details.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
+ expect(bubble).to_contain_text(LONG_ANSWER_END)
+
+
+# ---------------------------------------------------------------------------
+# 5. Restore: the one-shot landing still puts the latest message in view
+# (phase 14 behavior preserved — pinned so a future "remove all
+# scrolling" change fails loudly instead of silently)
+# ---------------------------------------------------------------------------
+
+
+def test_restore_lands_on_latest_message(page: Page, app_url: str, seeded_kb: None) -> None:
+ page.set_default_timeout(30_000)
+ page.goto(app_url)
+
+ # Two settled turns (user + brain × 2) — the document overflows.
+ submit(page, LONG_QUESTION)
+ wait_settled(page)
+ submit(page, LONG_QUESTION)
+ wait_settled(page)
+
+ page.reload()
+ # Restore re-renders from localStorage; wait until the last restored
+ # brain answer is fully back.
+ expect(page.locator(".msg.brain .bubble").last).to_contain_text(
+ LONG_ANSWER_END, timeout=30_000
+ )
+ wait_settled(page)
+ expect(page.locator(".msg.user .bubble")).to_have_count(2)
+ state = scroll_state(page)
+ assert state["sh"] > state["ch"]
+
+ # The forced one-shot landing (the only `force`d scrolls) puts the
+ # last brain message back in view ...
+ assert brain_message_in_view(page), (
+ "a restored conversation must land on its latest message"
+ )
+ # ... and the page sits at the bottom.
+ assert near_bottom(state)
diff --git a/tests/unit/test_chat_persistence.py b/tests/unit/test_chat_persistence.py
index a672738..dd05651 100644
--- a/tests/unit/test_chat_persistence.py
+++ b/tests/unit/test_chat_persistence.py
@@ -84,8 +84,10 @@ def test_raw_text_only_stored_and_re_rendered_on_restore() -> None:
on restore) — no HTML is ever stored. Restore re-applies the full
brain-message chrome: is-deflected styling, maybe-try chips, sources."""
js = _js()
- assert 'addMessage("user", renderMarkdown(m.text), "auto")' in js
- assert 'addMessage("brain", renderMarkdown(m.text), "auto")' in js
+ # Phase 18: restore landings are forced ("auto" + force) one-shot
+ # scrollReveal calls — the only forced scrolls in the app.
+ assert 'addMessage("user", renderMarkdown(m.text), "auto", true)' in js
+ assert 'addMessage("brain", renderMarkdown(m.text), "auto", true)' in js
assert "wrap.classList.add(\"is-deflected\")" in js
assert "appendMaybeTry(wrap, m.suggestions)" in js
assert "appendSources(wrap, m.sources)" in js
diff --git a/tests/unit/test_frontend_scroll.py b/tests/unit/test_frontend_scroll.py
new file mode 100644
index 0000000..094b4b6
--- /dev/null
+++ b/tests/unit/test_frontend_scroll.py
@@ -0,0 +1,139 @@
+"""Unit: the follow-the-bottom scroll contract in the static frontend
+(phase 18, owner choice 2026-08-23).
+
+The JS behavior itself is E2E-covered (tests/e2e/test_follow_bottom_scroll.py);
+here we pin the exported band constant and the single-gate markers that the
+story depends on — scrollIntoView appears exactly once in app.js, inside
+scrollReveal — so a silent regression back to unconditional per-delta /
+per-chunk scrolls is caught without a browser.
+"""
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
+APP_JS = FRONTEND / "assets" / "app.js"
+
+
+def _js() -> str:
+ return APP_JS.read_text(encoding="utf-8")
+
+
+def _fn_body(js: str, name: str) -> str:
+ """Source of the function starting at `function ` (to its closing
+ brace at column 0) — same slicing style as test_frontend_feedback.py."""
+ fn = js.find(f"function {name}")
+ assert fn != -1, f"{name} must exist in app.js"
+ return js[fn : js.find("\n}\n", fn)]
+
+
+def test_near_bottom_constant_exported_at_200px() -> None:
+ """The "pinned to the bottom" band (the composer zone) must be an
+ *exported* constant — unit-pinned, same pattern as TURN_TIMEOUT_MS."""
+ js = _js()
+ assert re.search(r"export\s+const\s+NEAR_BOTTOM_PX\s*=\s*200\s*;", js), (
+ "app.js must export `const NEAR_BOTTOM_PX = 200`"
+ )
+
+
+def test_is_near_bottom_uses_document_scroller() -> None:
+ """isNearBottom measures the DOCUMENT scroller (there is no inner
+ scroll container — the page scrolls on the window): distance from the
+ bottom of the document <= NEAR_BOTTOM_PX."""
+ js = _js()
+ body = _fn_body(js, "isNearBottom")
+ for ref in (
+ "documentElement.scrollHeight",
+ "window.scrollY",
+ "window.innerHeight",
+ "NEAR_BOTTOM_PX",
+ ):
+ assert ref in body, f"isNearBottom must reference {ref!r}"
+ assert "<=" in body, "the pinned band is an upper bound, not exact equality"
+
+
+def test_single_scroll_gate() -> None:
+ """scrollReveal is the ONE scroll call site in app.js: it fires only
+ when forced or when the user is pinned to the bottom, keeps
+ `block: "end"`, and both addMessage (behavior + force passthrough) and
+ addTyping (defaults) delegate to it."""
+ js = _js()
+ body = _fn_body(js, "scrollReveal")
+ assert "force || isNearBottom()" in body, "gate: force OR pinned to the bottom"
+ assert "scrollIntoView" in body
+ assert 'block: "end"' in body
+ # The regression pin: exactly one scrollIntoView in the whole file, and
+ # it lives inside scrollReveal.
+ assert js.count("scrollIntoView") == 1, (
+ "app.js must call scrollIntoView exactly once (inside scrollReveal)"
+ )
+ assert js.find("scrollIntoView") > js.find("function scrollReveal")
+ # addMessage passes its behavior/force through; addTyping uses defaults.
+ add_body = _fn_body(js, "addMessage")
+ assert "scrollReveal(wrap, scrollBehavior, force)" in add_body
+ assert "force = false" in add_body
+ typing_body = _fn_body(js, "addTyping")
+ assert "scrollReveal(wrap)" in typing_body
+
+
+def test_submit_reveal_is_gated() -> None:
+ """Submit keeps the plain default call — no force: the gate decides,
+ and it does in real use because submitting from the composer means the
+ user is pinned (inside the 200px band); a submit with the viewport away
+ from the bottom does not yank it."""
+ js = _js()
+ send = js.find("async function handleSend")
+ assert send != -1, "handleSend must exist"
+ call = 'addMessage("user", renderMarkdown(text));'
+ idx = js.find(call, send)
+ assert idx != -1, "handleSend must reveal the user message via the plain default"
+ assert 'addMessage("user", renderMarkdown(text),' not in js, (
+ "the submit call must not pass a third/fourth argument (no force)"
+ )
+
+
+def test_restore_force_landing() -> None:
+ """Both restore call sites are the only `force`d scrolls: one-shot,
+ non-smooth ("auto") landing on the last restored message (phase-14
+ behavior preserved)."""
+ js = _js()
+ body = _fn_body(js, "renderStoredMessage")
+ assert 'addMessage("user", renderMarkdown(m.text), "auto", true)' in body
+ assert 'addMessage("brain", renderMarkdown(m.text), "auto", true)' in body
+ # Forced restores are restore-only: exactly two ("auto", true) sites.
+ assert js.count('"auto", true') == 2, "only the two restore calls may force"
+
+
+def test_streaming_scrolls_only_through_gate() -> None:
+ """The per-chunk scrolls that used to yank the viewport (the phase-17
+ thinking branch and the streaming delta branch) now go through
+ scrollReveal with no raw scrollIntoView at either call site; the
+ block's internal bottom-pinning (its own overflow, not the page) stays."""
+ js = _js()
+ thinking_idx = js.find('ev.type === "thinking"')
+ delta_idx = js.find('ev.type === "delta"')
+ done_idx = js.find('ev.type === "done"')
+ assert -1 < thinking_idx < delta_idx < done_idx
+ thinking_branch = js[thinking_idx:delta_idx]
+ delta_branch = js[delta_idx:done_idx]
+ assert "scrollReveal(wrap)" in thinking_branch
+ assert "scrollReveal(wrap)" in delta_branch
+ assert "scrollIntoView" not in thinking_branch
+ assert "scrollIntoView" not in delta_branch
+ assert "textEl.scrollTop = textEl.scrollHeight" in thinking_branch
+
+
+def test_turn_end_focus_does_not_scroll() -> None:
+ """The turn-end focus-back (phase 06's "always focus back") must not
+ move the viewport: focusing the composer while the user is scrolled up
+ would yank them to the bottom at the moment the turn ends — the exact
+ defect phase 18 removes. preventScroll keeps the keyboard flow.
+ startNewChat keeps plain focus (the list is cleared, nothing to yank
+ past)."""
+ js = _js()
+ finally_idx = js.find("// done | error → idle: always settle, always focus back")
+ assert finally_idx != -1, "the turn's finally block must exist"
+ block = js[finally_idx : js.find("\n}", finally_idx)]
+ assert 'input.focus({ preventScroll: true })' in block
+ assert "input.focus()" not in block