"""Unit: the pinned (sticky-bottom) composer in the static frontend (phase 52, owner direction 2026-08-30, TODO.md L3 — "The message input text box needs to be pinned to the bottom of the screen so it doesn't 'run away' from the user as they try to click 'stop'"). The chat page scrolls at the DOCUMENT level and `.chat-shell` (the centered 46rem column, PLAN §7.1) is the composer's sticky containing block, so `position: sticky; bottom: env(safe-area-inset-bottom)` on `.composer` pins the box to the viewport's bottom edge at every scroll position and lets it settle back into its normal flow position (above the footer) once the document bottom is reached. The pin is CSS-only: * `.composer` carries the sticky pair (``position: sticky`` + the notch-aware ``bottom`` inset) and keeps its solid ``--surface`` background, border, radius and shadow — a reply scrolling behind the pinned box must never show through it; * NO ``z-index`` is added to the composer (it already paints above ``.messages`` by DOM order, never overlaps the sticky header (z 20) and stays under the z-1000 document modal), and the pin is not undone in the ≤640px media query; * the sticky context survives: `.chat-shell` / `.app-main` / `.messages` gain no ``overflow`` and `.messages` gains no inner scroller — the page keeps scrolling at the document level (the phase-42 model); * ``index.html`` needs no change: the composer is already the LAST child of `.chat-shell` (the sticky shift range is that column's box) and the `#message-input` / `#send-btn` / `#send-status` markup is untouched; * ``app.js`` gains NO page-scroll call site — the phase-42 invariant (``scrollReveal`` is still the single ``window.scrollTo``; no ``scrollIntoView``, no ``window.scrollY``) is re-pinned here so the pin can never silently arrive in JS instead of CSS. The browser geometry itself is E2E-gated by the story suite (tests/e2e/test_pinned_composer.py, task 02); these are source pins in the house style (see tests/unit/test_frontend_scroll.py). """ from __future__ import annotations import re from html.parser import HTMLParser from pathlib import Path FRONTEND = Path(__file__).resolve().parents[2] / "frontend" STYLES_CSS = FRONTEND / "assets" / "styles.css" APP_JS = FRONTEND / "assets" / "app.js" INDEX_HTML = FRONTEND / "index.html" VOID_ELEMENTS = frozenset( { "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr", } ) def _css() -> str: assert STYLES_CSS.is_file(), f"missing {STYLES_CSS}" return STYLES_CSS.read_text(encoding="utf-8") def _js() -> str: assert APP_JS.is_file(), f"missing {APP_JS}" return APP_JS.read_text(encoding="utf-8") def _html() -> str: assert INDEX_HTML.is_file(), f"missing {INDEX_HTML}" return INDEX_HTML.read_text(encoding="utf-8") def _rule(css: str, selector: str) -> str: """The body of the rule whose selector line is exactly `selector` (multi-line block) — same slicing style as test_history_page.py.""" block = re.search(rf"^{re.escape(selector)} \{{\n([\s\S]*?)\n\}}", css, re.MULTILINE) assert block, f"styles.css must carry a `{selector} {{ … }}` rule" return block.group(1) def _walk(node: dict) -> list[dict]: """Every element in a parsed subtree (depth-first, document order).""" out = [node] for child in node["children"]: out.extend(_walk(child)) return out def _mobile_block(css: str) -> str: """The ≤640px media query body (the phase-07 responsive block).""" mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}\n", css) assert mobile, "the mobile media query must exist" return mobile.group(1) class _Tree(HTMLParser): """Minimal element tree of index.html — enough to ask "who is the last element child of `.chat-shell`?" without a DOM library.""" def __init__(self) -> None: super().__init__(convert_charrefs=True) self.root: dict = {"tag": "#root", "attrs": {}, "children": []} self._stack: list[dict] = [self.root] def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: node = {"tag": tag, "attrs": dict(attrs), "children": []} self._stack[-1]["children"].append(node) if tag not in VOID_ELEMENTS: self._stack.append(node) def handle_endtag(self, tag: str) -> None: for i in range(len(self._stack) - 1, 0, -1): if self._stack[i]["tag"] == tag: del self._stack[i:] return def find(self, classes: str) -> dict: """The first element whose `class` attribute contains `classes` (all of them, space-separated).""" wanted = classes.split() stack = [self.root] while stack: node = stack.pop(0) have = (node["attrs"].get("class") or "").split() if node["tag"] != "#root" and all(c in have for c in wanted): return node stack.extend(node["children"]) raise AssertionError(f"index.html must contain .{classes}") def _tree() -> _Tree: tree = _Tree() tree.feed(_html()) return tree # ---------- the sticky pin itself ---------- def test_composer_is_sticky_bottom() -> None: """`.composer` carries the sticky pair — `position: sticky` AND a `bottom` offset — inside its own rule. `bottom` is the notch-aware safe-area inset (on desktop `env()` resolves to 0, so the box sits flush with the viewport bottom; on a notched phone the composer clears the home indicator instead of hiding under it).""" body = _rule(_css(), ".composer") assert "position: sticky;" in body, ( "the composer must be sticky — phase 52 pins it to the viewport " "bottom so Stop is reachable without scrolling (TODO.md L3)" ) assert "bottom: env(safe-area-inset-bottom);" in body, ( "the sticky offset must be the safe-area inset (the phase-07 " "mobile contract: the composer stays reachable around the notch)" ) # `top` would pin it to the wrong edge (and fight the sticky header). assert "top:" not in body, "the composer pins the BOTTOM edge only" def test_composer_stays_opaque_behind_scrolled_messages() -> None: """The pinned box overlaps the message list while the page is scrolled: it keeps its SOLID `--surface` background plus border, radius and shadow (no glass/transparency), so a reply streaming behind it never shows through the input.""" body = _rule(_css(), ".composer") assert "background: var(--surface);" in body, ( "the composer needs an opaque background — messages must not " "show through the pinned box" ) assert "transparent" not in body and "rgb(" not in body, ( "no translucent background on the pinned composer" ) assert "border: 1px solid var(--line);" in body assert "border-radius: var(--radius);" in body assert "box-shadow: var(--shadow);" in body, ( "the elevation shadow separates the pinned box from the content " "scrolling behind it" ) def test_no_z_index_added_to_the_composer() -> None: """Locked assumption: NO z-index change. DOM order already paints the composer above `.messages`, it never reaches the sticky header (z 20) at the top and it must stay under the z-1000 document modal.""" body = _rule(_css(), ".composer") assert "z-index" not in body, ( "the composer stays unlayered — a z-index here could lift it over " "the sticky header (20) or the document modal (1000)" ) css = _css() assert re.search(r"\.app-header \{[\s\S]*?z-index: 20;", css), ( "the sticky header keeps its layer" ) assert re.search(r"^\.doc-modal \{[\s\S]*?z-index: 1000;", css, re.MULTILINE), ( "the document modal stays the topmost layer" ) def test_mobile_pin_not_undone() -> None: """≤640px: the pin must survive the responsive block — the mobile `.composer` rule only tightens the padding (it must not reset `position` or drop the box out of the sticky model), and the phone keeps its own `main { padding-bottom: env(safe-area-inset-bottom) }` flow padding under the settled box.""" mobile = _mobile_block(_css()) assert ".composer" in mobile, "the mobile composer rule must remain" mobile_composer = re.search(r"\.composer \{([^}]*)\}", mobile) assert mobile_composer, "the ≤640px block must keep the .composer rule" body = mobile_composer.group(1) assert "padding: 0.5rem;" in body assert "position" not in body, ( "the mobile rule must not reset the sticky position" ) assert "main { padding-bottom: env(safe-area-inset-bottom, 0); }" in mobile # ---------- the sticky context (no inner scroller, no clipping ancestor) ---------- def test_document_level_scroll_is_untouched() -> None: """Sticky resolves against the NEAREST SCROLLING ANCESTOR. The pin relies on that being the document, so no ancestor between the composer and the viewport may become a scroll container: `.chat-shell` and `.app-main` stay overflow-visible and `.messages` gains no inner scroller / height cap (that would move the scroll — and the phase-42 contract — into the message list).""" css = _css() for selector in (".chat-shell", ".app-main", ".messages"): body = _rule(css, selector) assert "overflow" not in body, ( f"{selector} must not clip/scroll — the composer pins to the " "document viewport, not to an inner scrollport" ) messages = _rule(css, ".messages") assert not re.search(r"(? None: """The composer must remain the LAST element child of `.chat-shell` (the sticky containing block): the sticky shift range is that column's box, so a sibling after the form would carve the range away and re-break the pin. The `#message-input` / `#send-btn` / `#send-status` markup is untouched (the pin ships no DOM change).""" shell = _tree().find("chat-shell") last = shell["children"][-1] assert last["tag"] == "form", ( "the composer
must stay the last child of .chat-shell" ) assert last["attrs"].get("id") == "composer" assert "novalidate" in last["attrs"], ( "phase 48: the composer form stays `novalidate` (a `required` " "input would swallow the Stop click)" ) ids = { node["attrs"].get("id") for node in _walk(last) if node["attrs"].get("id") } assert {"composer", "message-input", "send-btn", "send-status"} <= ids, ( "the composer markup contract ids must all be present (task 02 E2E " "clicks Stop from this pinned box)" ) # ---------- app.js: the pin adds no page scroll (phase-42 invariant) ---------- def test_app_js_gains_no_page_scroll_call_site() -> None: """The phase-42 never-auto-scroll contract, re-pinned for phase 52: the composer pin is CSS-only, so `app.js`'s scroll surface is byte-for-byte the phase-42 one — `scrollReveal`'s single `window.scrollTo` is still the ONLY page scroll, no `scrollIntoView` came back, nothing measures `window.scrollY`, and the only other scroll is the thinking window's own bottom pin (`textEl.scrollTop`, a block-internal clip, not the page). A JS-scrolled "pin" (an IntersectionObserver / sticky polyfill / scroll listener) would trip one of these assertions.""" js = _js() assert js.count("window.scrollTo(") == 1, "exactly one page scroll may exist" assert js.find("window.scrollTo(") > js.find("function scrollReveal"), ( "the one page scroll must live inside scrollReveal" ) assert js.count(".scrollIntoView(") == 0 assert "window.scrollY" not in js assert "addEventListener(\"scroll\"" not in js, ( "the pin must not install a scroll listener" ) assert "IntersectionObserver" not in js, ( "no JS sticky polyfill — the pin is `position: sticky`" ) assert js.count("textEl.scrollTop = textEl.scrollHeight") == 1, ( "the thinking window's internal pin stays, and stays the only one" ) def test_turn_end_focus_still_uses_preventscroll() -> None: """The turn-end focus-back lands on the pinned composer every turn — with the box now sticky it must still never move the viewport (`focus({ preventScroll: true })`, phase 42), or every finished turn would yank the reader to the bottom.""" js = _js() idx = js.find("// done | error | stop → idle: always settle, always focus back") assert idx != -1, "the turn's finally block must exist" block = js[idx : js.find("\n}", idx)] assert "input.focus({ preventScroll: true })" in block