"""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 72rem column — the .container width, phase 100) 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; Phase 65 (task 02, owner-locked A1, 2026-09-01, `TODO.md` L3): the pin moved up one level — the `.chat-bottom` wrapper (the row + this form, the LAST child of `.chat-shell`) carries `position: sticky; bottom: env(safe-area-inset-bottom, 0)` and is the unit that stays glued to the viewport bottom; the composer's own sticky pair above and the `.messages` grow stay exactly as phase 52 pinned them (the composer's sticky is redundant inside the wrapper but kept — and pinned here — for the computed-style contract). 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`` (phase 65 task 02, owner-locked A1): the row + composer are wrapped in the `.chat-bottom` unit — the wrapper (NO id) is now the LAST child of `.chat-shell` (the sticky shift range is still 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: """Phase 65 (task 02, owner-locked A1): the LAST element child of `.chat-shell` is the `.chat-bottom` wrapper — NO id (nothing in JS binds it; the bindings live on the inner elements, the move is pure HTML/CSS) — holding the `.chat-actions` row, the phase-104 `#char-count` counter, and the `#composer` form, in that order: the row + counter + composer are ONE sticky unit, and the wrapper owns the shell's bottom slot, so the sticky shift range is still that column's box (a sibling after it would carve the range away and re-break the pin). The composer form keeps `novalidate` and its contract ids.""" shell = _tree().find("chat-shell") last = shell["children"][-1] assert last["tag"] == "div" and ( last["attrs"].get("class") or "" ).split() == ["chat-bottom"], ( "the .chat-bottom wrapper must be the last child of .chat-shell" ) assert last["attrs"].get("id") is None, ( "the wrapper carries no id — the JS bindings live on the inner " "elements" ) kids = last["children"] assert len(kids) == 3, ( "the unit holds exactly three element children: .chat-actions, " "then #char-count (phase 104), then #composer" ) row, counter, form = kids assert row["tag"] == "div" and ( row["attrs"].get("class") or "" ).split() == ["chat-actions"], ( "the first child is the .chat-actions row (the New chat → Share " "DOM order is pinned by test_save_chat_ui.py)" ) # Phase 104 (owner 2026-09-12): the question-length counter — a # hidden-by-default

between the row and the composer (zero # height while hidden; a new child INSIDE the unit breaks no pin — # the sticky geometry below is untouched). assert counter["tag"] == "p" and counter["attrs"].get("id") == "char-count", ( "the second child is the phase-104 #char-count counter" ) assert (counter["attrs"].get("class") or "") == "char-count" assert "hidden" in counter["attrs"], ( "the counter ships hidden — it appears only from 80% of the " "4,000-char cap (app.js updateCharCount)" ) assert form["tag"] == "form" and form["attrs"].get("id") == "composer" assert "novalidate" in form["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(form) 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