Files
brain-of-reese/tests/unit/test_pinned_composer.py
T
ducoterra 8a1f99cb38 feat(web): move the chat action cluster to the pinned bottom and align the button sets
- task 01: relocate the .chat-actions row (New chat + Share, comments byte-identical with a Phase 65 note) from the top of the column to the bottom of .chat-shell, directly above the composer
- task 02 (owner-locked A1): wrap the row + #composer in ONE sticky .chat-bottom unit (position: sticky; bottom: env(safe-area-inset-bottom, 0), no z-index) — the pills stay at the bottom of the screen at every scroll position and settle into flow above the footer
- task 03 (owner-locked A2): right-align the bottom row to the column's right edge (justify-content: flex-end), mirroring the right-aligned Save-as-doc corner; the five action pills share one 44px / 999px-pill geometry
- task 04: dedicated Playwright suite tests/e2e/test_bottom_chat_actions.py (resting geometry, the A1 pin across the sticky range, A2 alignment + DOM order + mobile stack + 360px overflow bound + 44px touch targets, New chat / Share click-through) — green in isolation
- task 05: regression matrix green in isolation (pinned_composer 4, save_share_ux 5, chat_persistence 4, share_chat 4, chat_history 5, smoke 3); full gate green — unit + integration pass, app/ coverage 99% (>90%), ruff + pyright clean
2026-09-02 01:08:18 -04:00

401 lines
17 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;
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"(?<!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_chat_bottom_unit_is_last_child_of_the_chat_shell() -> 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 exactly the `.chat-actions` row and then the
`#composer` form, in that order: the row + 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) == 2, (
"the unit holds exactly two element children: .chat-actions, then "
"#composer"
)
row, 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)"
)
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