"""Unit: the "no scroll back" contract for the Thinking window (phase 21, owner choice 2026-08-24, roadmap A2). The live Thinking block is a scratchpad, not a transcript: the 320px window always shows the *live tail* of the reasoning stream. The whole functional change is one CSS property — ``details.thinking .thinking-text`` goes from ``overflow-y: auto`` (a user-scrollable window) to ``overflow-y: hidden`` (a live-tail clip). ``overflow: hidden`` still permits *programmatic* scrolling, so the phase-17 JS bottom-pin (``textEl.scrollTop = textEl.scrollHeight`` on every thinking chunk) is the sole scroller — wheel, drag, and keyboard scrolling stop working. The browser behavior itself is E2E-covered (tests/e2e/test_thinking_no_scroll.py); here we pin the CSS value + the owner-choice comment and the intact bottom-pin so a silent regression (``overflow-y`` back to ``auto``, pin removed) 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" STYLES_CSS = FRONTEND / "assets" / "styles.css" def _js() -> str: return APP_JS.read_text(encoding="utf-8") def _css() -> str: return STYLES_CSS.read_text(encoding="utf-8") def _thinking_text_rule(css: str) -> str: """Body of the `details.thinking .thinking-text { ... }` rule.""" rule = re.search( r"details\.thinking \.thinking-text \{([\s\S]*?)\n\}", css ) assert rule, "styles.css must style details.thinking .thinking-text" return rule.group(1) def test_thinking_text_is_live_tail_clip() -> None: """The window stays the fixed 320px clip (owner-confirmed: no auto-height growth) but is NO LONGER user-scrollable.""" body = _thinking_text_rule(_css()) assert "max-height: 320px" in body, "the 320px clip must stay" assert "overflow-y: hidden" in body, "the window must not scroll" assert "overflow-y: auto" not in body, "no user-scrollable window remains" assert "overflow-y: scroll" not in body def test_thinking_text_carries_owner_choice_comment() -> None: """The owner-choice comment explains WHY the window is a live tail — the phase-17 JS bottom-pin is the sole scroller.""" body = _thinking_text_rule(_css()) assert "owner choice 2026-08-24" in body assert "live tail" in body assert "sole scroller" in body def test_js_bottom_pin_intact_and_sole_scroller() -> None: """The live-tail mechanism (phase 17) must survive phase 21 untouched: the streaming `thinking` branch pins `textEl.scrollTop = textEl.scrollHeight` per chunk, and it is the ONLY scrollTop assignment in app.js (no new user-facing scroll code was added to the window).""" js = _js() pin = "textEl.scrollTop = textEl.scrollHeight" assert js.count(pin) == 1, "the bottom-pin must exist exactly once" # It lives in the streaming thinking branch (before the delta branch), # inside the `block.open` guard so closed blocks are not scrolled. thinking_idx = js.find('ev.type === "thinking"') delta_idx = js.find('ev.type === "delta"') assert -1 < thinking_idx < delta_idx thinking_branch = js[thinking_idx:delta_idx] assert pin in thinking_branch assert "if (block.open)" in thinking_branch def test_no_js_change_to_thinking_scroll_behavior() -> None: """Phase 21 is CSS-only: nothing else in app.js touches the .thinking-text scroll (no scroll-behavior, no wheel/touch handlers, no scrollIntoView on the block — the page-level reveal stays the phase-18 scrollReveal, which is not a .thinking-text scroller).""" js = _js() block_template = js.find('