Files
brain-of-reese/tests/unit/test_pinned_composer.py
T
ducoterra aba8615177 fix(chat): rest the composer at the viewport bottom — sticky alone left it mid-screen
Phase 52's first pass shipped `position: sticky; bottom` on `.composer` and
called the phase done, but the owner's requirement — "the chat message-input
textarea should be at the bottom of the screen" — still failed in the browser:
on an empty/short chat the input rested just under the empty state (~57% of
the viewport) with a dead band down to the footer.

`position: sticky` can only pull a box UP toward the scrollport's bottom edge;
it can never push a box DOWN to meet it, so on a page that does not overflow
it is a no-op. The old story suite only exercised an overflowing conversation
(one test even asserted the buggy resting position as expected), which is why
the half-fix passed.

- `.messages { flex: 1 1 auto }` — absorbs a short page's free space so the
  composer's resting in-flow position is the bottom of the full-height column
  (body min-height:100dvh -> .app-main flex:1 -> .chat-shell flex:1); basis
  stays `auto`, no height cap, no overflow — the document stays the scroller
- `.composer { bottom: env(safe-area-inset-bottom, 0) }` — the explicit 0
  fallback replaces the env()-only offset, which degraded to `auto` (no pin)
  wherever env() is unsupported
- E2E: `test_empty_chat_composer_sits_in_normal_flow` ->
  `..._at_the_screen_bottom` (chrome-only band below the resting composer);
  the phone suite now checks the resting position as well as the pinned one
- Unit pins: the flex-grow half and the full-height column are pinned, so the
  fix cannot silently regress to sticky-only

Still CSS-only — no DOM change, no JS, no new scroll call site (phase 42
never-auto-scroll contract intact), no z-index.

Verified: 1019 unit/integration tests pass (app/ coverage 99%), ruff and
pyright clean; tests/e2e/test_pinned_composer.py green in isolation (4), plus
the stop/autoscroll/persistence/mobile-nav suites and 14 layout/scroll
neighbours green in isolation.
2026-08-30 16:14:18 -04:00

374 lines
16 KiB
Python

"""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"(?<!min-)\bheight:", messages), (
"the message list must not become its own scroller (only the "
"phase-01 `min-height` floor is allowed) — `flex-grow` stretches it, "
"a `height` cap would clip the conversation instead"
)
# ---------- index.html: no DOM change needed ----------
def test_composer_is_last_child_of_the_chat_shell() -> 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 <form> 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