"""Unit: the pinned 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'"; owner revision the same day — the box had to be at the bottom of the screen on an EMPTY chat too, which sticky alone cannot do). 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. The pin is therefore TWO rules, and both are pinned here: * `.messages { flex: 1 1 auto }` absorbs the free space of a short page, so the composer's RESTING (in-flow) position is already the bottom of the full-height column — `position: sticky` can only pull a box UP toward the viewport's bottom edge, it can never push one DOWN to meet it, so without the grow the empty/short chat left the input mid-screen with a dead band under it (the reported bug); * `.composer { position: sticky; bottom: env(safe-area-inset-bottom, 0) }` takes over the moment the conversation overflows the viewport and keeps the box — and the Stop control inside it — glued to the viewport's bottom edge at every scroll position, settling back into flow (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, with the explicit ``0`` fallback) 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; * 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 with an explicit `0` fallback (it resolves to 0 on a desktop viewport, so the box sits flush with the viewport bottom; on a notched phone the composer clears the home indicator instead of hiding under it — and a browser without `env()` support still gets 0).""" 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, 0);" in body, ( "the sticky offset must be the safe-area inset with a 0 fallback " "(the phase-07 mobile contract: the composer stays reachable " "around the notch, and never falls back to `auto` = no pin)" ) # `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_message_list_absorbs_the_short_page_space() -> None: """The other half of the pin — and the half phase 52 originally missed. `position: sticky` never pushes a box DOWN to the viewport's bottom edge, so on an empty/short chat (no free space absorbed) the composer sat right under the empty state, mid-screen. `.messages` must GROW to take up that space, which puts the composer's resting position at the bottom of the full-height column the page already builds (`body{min-height:100dvh}` → `.app-main{flex:1}` → `.chat-shell{flex:1}`). """ messages = _rule(_css(), ".messages") assert re.search(r"flex:\s*1(\s+1\s+auto)?;", messages), ( "the message list must grow to fill the short page — otherwise the " "pinned composer only works once the conversation overflows and the " "empty chat leaves the input mid-screen (owner revision 2026-08-30)" ) # flex-basis must stay `auto`: a `0` basis would size the list BELOW its # content when the conversation overflows and let bubbles overlap the box. assert not re.search(r"flex:\s*1\s+1\s+0", messages), ( "flex-basis must stay auto — the list keeps its content height when " "the page overflows (no free space to absorb there anyway)" ) # The column the composer sits in must keep stretching to the viewport. for selector in (".app-main", ".chat-shell"): frame = _rule(_css(), selector) assert re.search(r"flex:\s*1[;\s]", frame), ( f"{selector} must keep growing to the viewport height — the " "composer can only rest at the bottom of a full-height column" ) 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